Compare commits
29 Commits
codex/cove
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162f8643d2 | ||
|
|
77f531803c | ||
|
|
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 |
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
|
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 |
@@ -28,12 +28,7 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
|
Before Width: | Height: | Size: 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>
|
||||
|
||||
@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
@@ -260,9 +260,9 @@ async function handleBuy() {
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
|
||||
// On the consolidated (workspace) billing flow, show the workspace settings
|
||||
// panel; otherwise show the legacy subscription/credits panel.
|
||||
const settingsPanel = shouldUseWorkspaceBilling.value
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
|
||||
@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -34,29 +35,19 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
checkForCompletedTopup: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => mockTelemetry
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const mockBillingRouting = vi.hoisted(() => ({
|
||||
shouldUseWorkspaceBilling: false
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldUseWorkspaceBilling = ref(false)
|
||||
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
||||
get: () => shouldUseWorkspaceBilling.value,
|
||||
set: (value: boolean) => {
|
||||
shouldUseWorkspaceBilling.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
@@ -77,10 +68,7 @@ const i18n = createI18n({
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model',
|
||||
loadEventsError: 'Failed to load activity. Please try again.',
|
||||
loadEventsUnknownError:
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
model: 'Model'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,11 +95,6 @@ const AutoRefreshWrapper = defineComponent({
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
@@ -154,7 +137,7 @@ describe('UsageLogsTable', () => {
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = false
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -245,7 +228,7 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a localized fallback instead of a raw Error message', async () => {
|
||||
it('shows error message when service throws', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
@@ -253,25 +236,7 @@ describe('UsageLogsTable', () => {
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized fallback when the service reports no message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = null
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Failed to load activity. Please try again.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,8 +341,8 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
@@ -387,90 +352,6 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveLegacy(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs top-up completion telemetry for a superseded response', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const legacyResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
resolveLegacy(legacyResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
||||
legacyResponse.events
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
@@ -109,15 +109,14 @@ import {
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -140,12 +139,7 @@ const tooltipContentMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// A billing-route flip can overlap two loads against different backends; only
|
||||
// the latest may mutate state, so a superseded response is discarded.
|
||||
let latestLoadToken = 0
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadToken = ++latestLoadToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -154,17 +148,10 @@ const loadEvents = async () => {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = shouldUseWorkspaceBilling.value
|
||||
const response = useBillingApi.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
// Completion telemetry must run even when a mid-checkout route flip
|
||||
// supersedes this load, since legacy and workspace backends emit different
|
||||
// top-up events and the winning fetch may not carry the completion yet.
|
||||
useTelemetry()?.checkForCompletedTopup(response?.events)
|
||||
|
||||
if (loadToken !== latestLoadToken) return
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
@@ -178,25 +165,24 @@ const loadEvents = async () => {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total != null) {
|
||||
if (response.total) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages != null) {
|
||||
if (response.totalPages) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
const legacyError = shouldUseWorkspaceBilling.value
|
||||
? null
|
||||
: customerEventService.error.value
|
||||
error.value = legacyError || t('credits.loadEventsError')
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
} catch (err) {
|
||||
if (loadToken !== latestLoadToken) return
|
||||
error.value = t('credits.loadEventsUnknownError')
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
if (loadToken === latestLoadToken) loading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,12 +198,6 @@ const refresh = async () => {
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
watch(shouldUseWorkspaceBilling, () => {
|
||||
refresh().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,7 +19,6 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +26,6 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,23 +57,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -165,7 +151,6 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -177,27 +162,16 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -298,7 +272,6 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -307,27 +280,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -339,20 +294,6 @@ describe('useBillingContext', () => {
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never clobbers the list-derived store when a subscription is absent', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
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
|
||||
@@ -17,10 +17,10 @@ import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
@@ -34,9 +34,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -69,7 +68,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { type } = useBillingRouting()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
@@ -96,6 +95,16 @@ function useBillingContextInternal(): BillingContext {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
@@ -160,12 +169,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher.
|
||||
// Subscribed means active AND not cancelled, so the delete button enables
|
||||
// after cancellation, even before the period ends. A null subscription means
|
||||
// "not loaded yet" (adapters are discarded on every workspace/type switch);
|
||||
// skip it so the transient reinit gap can't clobber the list-derived baseline
|
||||
// (personal workspaces and subscribed teams already read subscribed there).
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
@@ -179,27 +185,24 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
|
||||
// init detect that it was superseded (its captured adapter is no longer the
|
||||
// active one), so a stale response can't resolve into a ready state for the
|
||||
// wrong workspace.
|
||||
function resetBillingState() {
|
||||
legacyBillingRef.value = null
|
||||
workspaceBillingRef.value = null
|
||||
isInitialized.value = false
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
resetBillingState()
|
||||
if (!newWorkspaceId) return
|
||||
if (!newWorkspaceId) {
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
@@ -212,20 +215,17 @@ function useBillingContextInternal(): BillingContext {
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
const adapter = activeContext.value
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await adapter.initialize()
|
||||
if (activeContext.value !== adapter) return
|
||||
await activeContext.value.initialize()
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
if (activeContext.value !== adapter) return
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
if (activeContext.value === adapter) isLoading.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,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 {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const personal = { id: 'w-personal', type: 'personal' as const }
|
||||
const team = { id: 'w-team', type: 'team' as const }
|
||||
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
it('uses legacy billing when team workspaces are disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type { BillingType } from './types'
|
||||
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
|
||||
// An unloaded workspace has no type yet; stay legacy so bootstrap never
|
||||
// eagerly routes to workspace billing.
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
return 'workspace'
|
||||
})
|
||||
|
||||
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
|
||||
|
||||
return { type, shouldUseWorkspaceBilling }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -6,7 +5,6 @@ import type { Ref, ShallowRef } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -18,7 +16,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const ctxObj: unknown = {
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
@@ -35,28 +33,13 @@ const ctxObj: unknown = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
}
|
||||
const ctx = ctxObj as CanvasRenderingContext2D
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function makeCanvas(
|
||||
options: {
|
||||
context?: CanvasRenderingContext2D | null
|
||||
clientWidth?: number
|
||||
clientHeight?: number
|
||||
} = {}
|
||||
): HTMLCanvasElement {
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
const el = document.createElement('canvas')
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: options.clientWidth ?? 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
value: options.clientHeight ?? 100,
|
||||
configurable: true
|
||||
})
|
||||
const getCtx: unknown = () =>
|
||||
options.context === undefined ? ctx : options.context
|
||||
el.getContext = getCtx as HTMLCanvasElement['getContext']
|
||||
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
|
||||
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -91,7 +74,7 @@ const pe = (
|
||||
clientY: number,
|
||||
over: Partial<PointerEvent> = {}
|
||||
) =>
|
||||
fromPartial<PointerEvent>({
|
||||
({
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY,
|
||||
@@ -100,7 +83,7 @@ const pe = (
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
...over
|
||||
})
|
||||
}) as unknown as PointerEvent
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
@@ -113,14 +96,14 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
function setup(initial: BoundingBox[] | undefined = []) {
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
|
||||
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
|
||||
const modelValue = ref(initial as BoundingBox[])
|
||||
const modelValue = ref(initial)
|
||||
const api = useBoundingBoxes(toNodeId('1'), {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
@@ -176,43 +159,9 @@ describe('useBoundingBoxes initialization', () => {
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.activeRegion.value).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to default dimensions when the litegraph node is unavailable', () => {
|
||||
appState.node = null
|
||||
const c = setup([box()])
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
|
||||
})
|
||||
|
||||
it('ignores non-positive dimension widgets', () => {
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 0 },
|
||||
{ name: 'height', value: 'bad' }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
const c = setup()
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
|
||||
})
|
||||
|
||||
it('treats an undefined model value as empty', () => {
|
||||
const c = setup(undefined)
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.modelValue.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes drawing', () => {
|
||||
it('ignores non-primary pointer buttons', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10, { button: 1 }))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('draws a new region and syncs it to the model value', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
@@ -238,131 +187,6 @@ describe('useBoundingBoxes drawing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('moves an existing active region by dragging inside it', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onCanvasPointerMove(pe(45, 50))
|
||||
c.onDocPointerUp(pe(45, 50))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].x).toBeGreaterThan(51)
|
||||
expect(c.modelValue.value[0].y).toBeGreaterThan(51)
|
||||
})
|
||||
|
||||
it('resizes an existing active region from its corner handle', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(60, 60))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(256)
|
||||
expect(c.modelValue.value[0].height).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
it('keeps selection valid when Alt-clicking overlapping regions', async () => {
|
||||
const c = setup([
|
||||
box(),
|
||||
box({
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: 'second',
|
||||
palette: ['#ff0000']
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.onPointerDown(pe(30, 30, { altKey: true }))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores document movement and pointer up when no draw is active', async () => {
|
||||
const c = setup([box()])
|
||||
|
||||
c.onCanvasPointerMove(pe(5, 95))
|
||||
c.onDocPointerUp(pe(95, 95))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses zero pointer coordinates when the canvas is unavailable', async () => {
|
||||
const c = setup()
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onPointerDown(pe(50, 50))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('redraws active text regions with the fallback palette color', async () => {
|
||||
const fillStyles: string[] = []
|
||||
const fillText = vi.fn()
|
||||
const recordingCtx: unknown = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText,
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
set fillStyle(value: string) {
|
||||
fillStyles.push(value)
|
||||
},
|
||||
get fillStyle() {
|
||||
return fillStyles.at(-1) ?? ''
|
||||
}
|
||||
}
|
||||
const c = setup([
|
||||
box({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
metadata: {
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
desc: 'alpha beta\n\ncharlie',
|
||||
palette: []
|
||||
}
|
||||
})
|
||||
])
|
||||
c.canvasEl.value = makeCanvas({
|
||||
context: recordingCtx as CanvasRenderingContext2D
|
||||
})
|
||||
|
||||
c.focused.value = true
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(fillText).toHaveBeenCalled()
|
||||
expect(fillStyles.some((s) => s.includes('#8c8c8c'))).toBe(true)
|
||||
})
|
||||
|
||||
it('draws safely when the canvas context is unavailable', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes region editing', () => {
|
||||
@@ -375,13 +199,11 @@ describe('useBoundingBoxes region editing', () => {
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
@@ -392,74 +214,12 @@ describe('useBoundingBoxes region editing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does nothing when changing type without an active region', async () => {
|
||||
const c = setup()
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes the active region on Backspace', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Backspace',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores unrelated keys and key events while drawing', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
c.onPointerDown(pe(80, 80))
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent while drawing')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a remaining region selected after deleting from a multi-region list', async () => {
|
||||
const c = setup([box(), box({ x: 10 })])
|
||||
|
||||
c.onCanvasKeyDown(
|
||||
fromPartial<KeyboardEvent>({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
})
|
||||
)
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes inline editor', () => {
|
||||
it('opens on double click and commits the description', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
|
||||
@@ -472,91 +232,11 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
|
||||
it('closes the inline editor on Escape', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('commits the inline editor on Ctrl+Enter', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
|
||||
it('commits the inline editor on Meta+Enter', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'meta committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: true
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('meta committed')
|
||||
})
|
||||
|
||||
it('ignores Enter without a modifier in the inline editor', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'not committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('')
|
||||
})
|
||||
|
||||
it('leaves state unchanged when committing without an editor', async () => {
|
||||
const c = setup([box()])
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('')
|
||||
})
|
||||
|
||||
it('closes a stale inline editor after its region was removed', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'stale'
|
||||
|
||||
c.clearAll()
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not open the inline editor when double-clicking empty space', async () => {
|
||||
const c = setup([box({ x: 0, y: 0, width: 50, height: 50 })])
|
||||
c.onDoubleClick(pe(95, 95) as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('uses zero mouse coordinates when double-clicking without a canvas', async () => {
|
||||
const c = setup([box({ x: 0, y: 0, width: 512, height: 512 })])
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onDoubleClick(pe(30, 30) as MouseEvent)
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
@@ -567,74 +247,4 @@ describe('useBoundingBoxes hover cursor', () => {
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
|
||||
it('returns to the default cursor after leaving the canvas', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
c.onPointerLeave()
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('does nothing when leaving without hover state', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerLeave()
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('keeps cursor default when canvas context is unavailable for title hit testing', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
c.onCanvasPointerMove(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('keeps hover state unchanged when pointer movement hits the same tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes background image', () => {
|
||||
it('loads a background image and snaps node dimensions', async () => {
|
||||
const widthCallback = vi.fn()
|
||||
const heightCallback = vi.fn()
|
||||
const inputNode = { id: 2 }
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512, callback: widthCallback },
|
||||
{ name: 'height', value: 512, callback: heightCallback }
|
||||
],
|
||||
findInputSlot: () => 0,
|
||||
getInputNode: () => inputNode
|
||||
}
|
||||
const store = useNodeOutputStore()
|
||||
vi.spyOn(store, 'getNodeImageUrls').mockReturnValue(['blob:bg'])
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 257
|
||||
naturalHeight = 271
|
||||
onload: (() => void) | null = null
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.()
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
|
||||
setup([box()])
|
||||
await flush()
|
||||
|
||||
expect(widthCallback).toHaveBeenCalledWith(256)
|
||||
expect(heightCallback).toHaveBeenCalledWith(272)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
type Graph = {
|
||||
isRootGraph: boolean
|
||||
}
|
||||
|
||||
type FocusableNode = {
|
||||
graph?: Graph
|
||||
boundingRect: DOMRect
|
||||
}
|
||||
|
||||
const { appState, canvasStore, getNodeByExecutionId } = vi.hoisted(() => ({
|
||||
appState: {
|
||||
rootGraph: { isRootGraph: true }
|
||||
},
|
||||
canvasStore: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
graph: Graph
|
||||
subgraph?: Graph
|
||||
setGraph: ReturnType<typeof vi.fn>
|
||||
animateToBounds: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appState
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getNodeByExecutionId.mockReset()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
(callback: FrameRequestCallback): number => {
|
||||
callback(0)
|
||||
return 1
|
||||
}
|
||||
)
|
||||
canvasStore.canvas = {
|
||||
graph: appState.rootGraph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
describe('useFocusNode', () => {
|
||||
it('does nothing when there is no canvas or matching graph node', async () => {
|
||||
canvasStore.canvas = undefined
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
|
||||
canvasStore.canvas = {
|
||||
graph: appState.rootGraph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
getNodeByExecutionId.mockReturnValue({ boundingRect: new DOMRect() })
|
||||
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(canvasStore.canvas.animateToBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates to the node graph before focusing its bounds', async () => {
|
||||
const subgraph = { isRootGraph: false }
|
||||
const bounds = new DOMRect(1, 2, 3, 4)
|
||||
getNodeByExecutionId.mockReturnValue({
|
||||
graph: subgraph,
|
||||
boundingRect: bounds
|
||||
} satisfies FocusableNode)
|
||||
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(getNodeByExecutionId).toHaveBeenCalledWith(
|
||||
appState.rootGraph,
|
||||
'node-1'
|
||||
)
|
||||
expect(canvasStore.canvas?.subgraph).toBe(subgraph)
|
||||
expect(canvasStore.canvas?.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(canvasStore.canvas?.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
|
||||
it('uses an execution id map and skips graph navigation when already there', async () => {
|
||||
const graph = { isRootGraph: true }
|
||||
const bounds = new DOMRect(5, 6, 7, 8)
|
||||
canvasStore.canvas = {
|
||||
graph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
const node = { graph, boundingRect: bounds } satisfies FocusableNode
|
||||
|
||||
await useFocusNode().focusNode(
|
||||
'node-1',
|
||||
new Map([['node-1', createMockLGraphNode(node)]])
|
||||
)
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
canvas: {},
|
||||
ds: {
|
||||
scale: 3
|
||||
}
|
||||
},
|
||||
canvasPosToClientPos: vi.fn((pos: [number, number]) => [
|
||||
pos[0] + 10,
|
||||
pos[1] + 20
|
||||
]),
|
||||
getCanvas: vi.fn(),
|
||||
getSetting: vi.fn(),
|
||||
updateCanvasPosition: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mocks.getSetting
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useCanvasPositionConversion: vi.fn(() => ({
|
||||
canvasPosToClientPos: mocks.canvasPosToClientPos,
|
||||
update: mocks.updateCanvasPosition
|
||||
}))
|
||||
}))
|
||||
|
||||
const { useAbsolutePosition } = await import('./useAbsolutePosition')
|
||||
|
||||
describe('useAbsolutePosition', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getCanvas.mockReturnValue(mocks.canvas)
|
||||
mocks.canvas.ds.scale = 3
|
||||
})
|
||||
|
||||
it('positions and scales an element with the canvas scale', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5]
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
position: 'fixed',
|
||||
left: '11px',
|
||||
top: '22px',
|
||||
width: '12px',
|
||||
height: '15px'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an explicit scale when provided', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5],
|
||||
scale: 2
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
width: '8px',
|
||||
height: '10px'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies transform scaling without resizing the element bounds', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition({
|
||||
useTransform: true
|
||||
})
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5],
|
||||
scale: 2
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
position: 'fixed',
|
||||
transformOrigin: '0 0',
|
||||
transform: 'scale(2)',
|
||||
left: '11px',
|
||||
top: '22px',
|
||||
width: '4px',
|
||||
height: '5px'
|
||||
})
|
||||
})
|
||||
|
||||
it('recomputes the canvas position when layout settings change', async () => {
|
||||
const sidebarLocation = ref('left')
|
||||
mocks.getSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Sidebar.Location' ? sidebarLocation.value : undefined
|
||||
)
|
||||
|
||||
useAbsolutePosition()
|
||||
expect(mocks.updateCanvasPosition).not.toHaveBeenCalled()
|
||||
|
||||
sidebarLocation.value = 'right'
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.updateCanvasPosition).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const canvasObj: unknown = {
|
||||
canvas: {},
|
||||
ds: {
|
||||
offset: [10, 20],
|
||||
scale: 2
|
||||
}
|
||||
}
|
||||
const canvas = canvasObj as LGraphCanvas
|
||||
|
||||
return {
|
||||
bounds: {
|
||||
left: { value: 4 },
|
||||
top: { value: 6 }
|
||||
},
|
||||
canvas,
|
||||
getCanvas: vi.fn(() => canvas),
|
||||
update: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementBounding: vi.fn(() => ({
|
||||
left: mocks.bounds.left,
|
||||
top: mocks.bounds.top,
|
||||
update: mocks.update
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
const { useCanvasPositionConversion, useSharedCanvasPositionConversion } =
|
||||
await import('./useCanvasPositionConversion')
|
||||
|
||||
describe('useCanvasPositionConversion', () => {
|
||||
beforeEach(() => {
|
||||
mocks.bounds.left.value = 4
|
||||
mocks.bounds.top.value = 6
|
||||
mocks.getCanvas.mockClear()
|
||||
mocks.update.mockClear()
|
||||
})
|
||||
|
||||
it('converts client positions into canvas coordinates', () => {
|
||||
const { clientPosToCanvasPos } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
expect(clientPosToCanvasPos([34, 66])).toEqual([5, 10])
|
||||
})
|
||||
|
||||
it('converts canvas positions into client coordinates', () => {
|
||||
const { canvasPosToClientPos } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
expect(canvasPosToClientPos([5, 10])).toEqual([34, 66])
|
||||
})
|
||||
|
||||
it('returns the element bounds update callback', () => {
|
||||
const { update } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
update()
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the shared converter instance', () => {
|
||||
const first = useSharedCanvasPositionConversion()
|
||||
const second = useSharedCanvasPositionConversion()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(mocks.getCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
|
||||
import { useOverflowObserver } from './useOverflowObserver'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMutationObserver: vi.fn(() => ({ stop: vi.fn() })),
|
||||
useResizeObserver: vi.fn(() => ({ stop: vi.fn() }))
|
||||
}))
|
||||
|
||||
const useMutationObserverMock = vi.mocked(useMutationObserver)
|
||||
const useResizeObserverMock = vi.mocked(useResizeObserver)
|
||||
|
||||
function setElementWidths(
|
||||
element: HTMLElement,
|
||||
widths: { scrollWidth: number; clientWidth: number }
|
||||
) {
|
||||
Object.defineProperty(element, 'scrollWidth', {
|
||||
value: widths.scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
value: widths.clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('useOverflowObserver', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useMutationObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
|
||||
useResizeObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
|
||||
})
|
||||
|
||||
it('checks overflow immediately when debounce is disabled', () => {
|
||||
const element = document.createElement('div')
|
||||
const onCheck = vi.fn()
|
||||
setElementWidths(element, { scrollWidth: 120, clientWidth: 100 })
|
||||
|
||||
const observer = useOverflowObserver(element, {
|
||||
debounceTime: 0,
|
||||
onCheck
|
||||
})
|
||||
|
||||
observer.checkOverflow()
|
||||
|
||||
expect(observer.isOverflowing.value).toBe(true)
|
||||
expect(onCheck).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('can skip observers and still dispose', () => {
|
||||
const element = document.createElement('div')
|
||||
|
||||
const observer = useOverflowObserver(element, {
|
||||
useMutationObserver: false,
|
||||
useResizeObserver: false
|
||||
})
|
||||
|
||||
observer.dispose()
|
||||
|
||||
expect(observer.disposed.value).toBe(true)
|
||||
expect(useMutationObserverMock).not.toHaveBeenCalled()
|
||||
expect(useResizeObserverMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops enabled observers on dispose', () => {
|
||||
const element = document.createElement('div')
|
||||
const stopMutation = vi.fn()
|
||||
const stopResize = vi.fn()
|
||||
useMutationObserverMock.mockReturnValue(fromPartial({ stop: stopMutation }))
|
||||
useResizeObserverMock.mockReturnValue(fromPartial({ stop: stopResize }))
|
||||
|
||||
const observer = useOverflowObserver(element)
|
||||
|
||||
observer.dispose()
|
||||
|
||||
expect(stopMutation).toHaveBeenCalledOnce()
|
||||
expect(stopResize).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import {
|
||||
@@ -360,203 +360,5 @@ describe('contextMenuConverter', () => {
|
||||
)
|
||||
expect(hasExtensionsCategory).toBe(true)
|
||||
})
|
||||
|
||||
it('skips items without content and duplicate equivalents', () => {
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{ content: '', callback: () => {} },
|
||||
{ content: 'Duplicate', callback: () => {} },
|
||||
{ content: 'Clone', callback: () => {} }
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.map((option) => option.label)).toEqual(['Duplicate'])
|
||||
})
|
||||
|
||||
it('wraps callbacks and reports callback errors', () => {
|
||||
const callback = vi.fn()
|
||||
const error = new Error('callback failed')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{ content: 'Run', value: 'run-value', callback },
|
||||
{
|
||||
content: 'Broken',
|
||||
callback: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ content: 'Disabled', disabled: true, callback: () => {} }
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
result[0].action?.()
|
||||
result[1].action?.()
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
'run-value',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: 'Run' })
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error executing context menu callback:',
|
||||
error
|
||||
)
|
||||
expect(result[2].action).toBeUndefined()
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('converts static submenus and submenu callbacks', () => {
|
||||
const submenuCallback = vi.fn()
|
||||
const error = new Error('submenu failed')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Static Submenu',
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: [
|
||||
'<b>ignored string without callback</b>',
|
||||
null,
|
||||
{
|
||||
content: '<b>Choice</b>',
|
||||
value: 'choice',
|
||||
callback: submenuCallback
|
||||
},
|
||||
{
|
||||
content: '<i>Disabled</i>',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
content: '<span>Broken</span>',
|
||||
callback: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ content: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const submenu = result[0].submenu ?? []
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(submenu.map((option) => option.label)).toEqual([
|
||||
'<b>ignored string without callback</b>',
|
||||
'Choice',
|
||||
'Disabled',
|
||||
'Broken'
|
||||
])
|
||||
expect(submenu[2].disabled).toBe(true)
|
||||
|
||||
submenu[1].action?.()
|
||||
submenu[3].action?.()
|
||||
|
||||
expect(submenuCallback).toHaveBeenCalledWith(
|
||||
'choice',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: '<b>Choice</b>' })
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error executing submenu callback:',
|
||||
error
|
||||
)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('captures dynamic submenus created by callbacks', () => {
|
||||
const stringCallback = vi.fn()
|
||||
const objectCallback = vi.fn()
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Dynamic Submenu',
|
||||
has_submenu: true,
|
||||
callback: () => {
|
||||
new LiteGraph.ContextMenu(
|
||||
[
|
||||
'Auto',
|
||||
{
|
||||
content: '<b>Object choice</b>',
|
||||
value: 'object',
|
||||
callback: objectCallback
|
||||
}
|
||||
],
|
||||
{ callback: stringCallback, extra: { source: 'test' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const submenu = result[0].submenu ?? []
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(submenu.map((option) => option.label)).toEqual([
|
||||
'Auto',
|
||||
'Object choice'
|
||||
])
|
||||
|
||||
submenu[0].action?.()
|
||||
submenu[1].action?.()
|
||||
|
||||
expect(stringCallback).toHaveBeenCalledWith(
|
||||
'Auto',
|
||||
expect.objectContaining({ extra: { source: 'test' } }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ source: 'test' }
|
||||
)
|
||||
expect(objectCallback).toHaveBeenCalledWith(
|
||||
'object',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: '<b>Object choice</b>' })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when dynamic submenu callbacks fail to provide items', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Empty Dynamic Submenu',
|
||||
has_submenu: true,
|
||||
callback: () => {}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(result[0].submenu).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ContextMenuConverter] No items captured for:',
|
||||
'Empty Dynamic Submenu'
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ContextMenuConverter] Failed to capture submenu for:',
|
||||
'Empty Dynamic Submenu'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -21,7 +20,6 @@ import * as missingModelScan from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
@@ -132,46 +130,6 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when a connected input has no root graph', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when a connected input has no slot name', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
const nullSlot: unknown = null
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
12,
|
||||
true,
|
||||
null,
|
||||
nullSlot as INodeInputSlot
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears errors for pure input slots without widget property', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
@@ -271,38 +229,9 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const noGraph: unknown = undefined
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(noGraph as LGraph)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when the host execution id is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(otherGraph)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
|
||||
fromAny<LGraph, unknown>(undefined)
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
@@ -462,124 +391,6 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
|
||||
it('removes unhooked nodes without restoring callbacks', () => {
|
||||
const graph = new LGraph()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('late')
|
||||
expect(() => graph.onNodeRemoved!(node)).not.toThrow()
|
||||
expect(node.onConnectionsChange).toBeUndefined()
|
||||
expect(node.onWidgetChanged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores recursively installed callbacks on subgraph cleanup', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('inner')
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const originalOnWidgetChanged = vi.fn()
|
||||
innerNode.onConnectionsChange = originalOnConnectionsChange
|
||||
innerNode.onWidgetChanged = originalOnWidgetChanged
|
||||
subgraph.add(innerNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraph.rootGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
|
||||
expect(innerNode.onConnectionsChange).not.toBe(originalOnConnectionsChange)
|
||||
expect(innerNode.onWidgetChanged).not.toBe(originalOnWidgetChanged)
|
||||
|
||||
cleanup()
|
||||
|
||||
expect(innerNode.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
expect(innerNode.onWidgetChanged).toBe(originalOnWidgetChanged)
|
||||
})
|
||||
|
||||
it('restores undefined graph hooks when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls original graph hooks for added, removed, and trigger events', () => {
|
||||
const graph = new LGraph()
|
||||
const onNodeAdded = vi.fn()
|
||||
const onNodeRemoved = vi.fn()
|
||||
const onTrigger = vi.fn()
|
||||
graph.onNodeAdded = onNodeAdded
|
||||
graph.onNodeRemoved = onNodeRemoved
|
||||
graph.onTrigger = onTrigger
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('test')
|
||||
graph.onNodeAdded!(node)
|
||||
graph.onNodeRemoved!(node)
|
||||
graph.onTrigger!({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
oldValue: 'old',
|
||||
newValue: 'new'
|
||||
})
|
||||
|
||||
expect(onNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(onNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(onTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ property: 'title' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips scanning added nodes while graph loading is in progress', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
vi.spyOn(ChangeTracker, 'isLoadingGraph', 'get').mockReturnValue(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips scanning added nodes when root graph is unavailable', async () => {
|
||||
const graph = new LGraph()
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.add(new LGraphNode('CheckpointLoaderSimple'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
expect(mediaScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips scanning added inactive nodes', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.mode = LGraphEventMode.BYPASS
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans added-node missing models after widget values are restored', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
@@ -681,7 +492,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny<
|
||||
Parameters<typeof modelStore.setMissingModels>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
@@ -718,7 +532,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny<
|
||||
Parameters<typeof modelStore.setMissingModels>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
@@ -749,7 +566,10 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
|
||||
const mediaStore = useMissingMediaStore()
|
||||
mediaStore.setMissingMedia([
|
||||
fromPartial<MissingMediaCandidate>({
|
||||
fromAny<
|
||||
Parameters<typeof mediaStore.setMissingMedia>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
@@ -914,84 +734,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('logs pending model verification failures without surfacing candidates', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'broken.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockRejectedValue(new Error('nope'))
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('logs pending media verification failures without surfacing candidates', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'broken.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockRejectedValue(new Error('nope'))
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
@@ -1151,54 +893,6 @@ describe('realtime verification staleness guards', () => {
|
||||
// result must not be added to the store.
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified media when rootGraph switched before verification resolved', async () => {
|
||||
const graphA = new LGraph()
|
||||
const nodeA = new LGraphNode('LoadImage')
|
||||
graphA.add(nodeA)
|
||||
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'stale_from_A.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
|
||||
installErrorClearingHooks(graphA)
|
||||
|
||||
nodeA.mode = LGraphEventMode.ALWAYS
|
||||
graphA.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: nodeA.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
const graphB = new LGraph()
|
||||
graphB.add(new LGraphNode('LoadImage'))
|
||||
rootSpy.mockReturnValue(graphB)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
@@ -1310,168 +1004,6 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips inactive descendants during subgraph replay scans', async () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const activeNode = new LGraphNode('UNETLoader')
|
||||
const bypassedNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
subgraph.add(activeNode)
|
||||
subgraph.add(bypassedNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph,
|
||||
id: 205
|
||||
})
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScanSpy = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(rootGraph)
|
||||
|
||||
rootGraph.onNodeAdded?.(subgraphNode)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
activeNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
bypassedNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces missing node errors from the Unknown fallback type', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const noType: unknown = undefined
|
||||
node.type = noType as LGraphNode['type']
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(useMissingNodesErrorStore().missingNodesError?.nodeTypes).toEqual([
|
||||
expect.objectContaining({ type: 'Unknown', nodeId: String(node.id) })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not show the overlay when un-bypass finds no missing errors', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraph.rootGraph
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
const showOverlay = vi.spyOn(useExecutionErrorStore(), 'showErrorOverlay')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(showOverlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes that do not change active state', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.ALWAYS,
|
||||
newValue: LGraphEventMode.ON_EVENT
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes for missing local nodes', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: 999,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes when root graph is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes when the local node has no root execution id', () => {
|
||||
const graph = new LGraph()
|
||||
const rootGraph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
@@ -1480,7 +1012,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny<MissingModelCandidate, unknown>({
|
||||
nodeId: '65',
|
||||
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -1507,7 +1039,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const hostCandidate = fromPartial<MissingModelCandidate>({
|
||||
const hostCandidate = fromAny<MissingModelCandidate, unknown>({
|
||||
nodeId: '65',
|
||||
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mockSelectionState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
hasMultipleSelection: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn()
|
||||
}))
|
||||
|
||||
const mockTitleEditorStore = vi.hoisted(() => ({
|
||||
titleEditorTarget: null as null | object
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<object>(),
|
||||
graph: {
|
||||
add: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const hasMultipleSelection = ref(false)
|
||||
mockSelectionState.refs = {
|
||||
hasMultipleSelection
|
||||
}
|
||||
|
||||
return {
|
||||
useSelectionState: () => ({
|
||||
hasMultipleSelection
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useTitleEditorStore: () => mockTitleEditorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
describe('useFrameNodes', () => {
|
||||
let resizeToSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (mockSelectionState.refs) {
|
||||
mockSelectionState.refs.hasMultipleSelection.value = false
|
||||
}
|
||||
mockSettingStore.get.mockReturnValue(24)
|
||||
mockTitleEditorStore.titleEditorTarget = null
|
||||
mockApp.canvas.selectedItems = new Set()
|
||||
mockApp.canvas.graph = {
|
||||
add: vi.fn()
|
||||
}
|
||||
// Real LGraphGroup constructor; only resizeTo's own geometry math is
|
||||
// out of scope here (see LGraphGroup's own test coverage for that).
|
||||
resizeToSpy = vi
|
||||
.spyOn(LGraphGroup.prototype, 'resizeTo')
|
||||
.mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resizeToSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('exposes whether selected nodes can be framed', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { canFrame } = useFrameNodes()
|
||||
|
||||
expect(canFrame.value).toBe(false)
|
||||
|
||||
if (!mockSelectionState.refs) {
|
||||
throw new Error('selection refs were not initialized')
|
||||
}
|
||||
mockSelectionState.refs.hasMultipleSelection.value = true
|
||||
|
||||
expect(canFrame.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when no items are selected', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
expect(resizeToSpy).not.toHaveBeenCalled()
|
||||
expect(mockApp.canvas.graph.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('frames selected items and opens the title editor on the new group', async () => {
|
||||
const selectedNode = {}
|
||||
mockApp.canvas.selectedItems = new Set([selectedNode])
|
||||
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
const group = mockApp.canvas.graph.add.mock.calls[0]?.[0] as LGraphGroup
|
||||
expect(group).toBeInstanceOf(LGraphGroup)
|
||||
expect(resizeToSpy).toHaveBeenCalledWith(mockApp.canvas.selectedItems, 24)
|
||||
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
|
||||
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import {
|
||||
extractVueNodeData,
|
||||
getControlWidget,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
@@ -18,12 +13,9 @@ import {
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { app } from '@/scripts/app'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
beforeEach(() => {
|
||||
@@ -271,26 +263,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps widget slot metadata even when the input slot name is empty', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toMatchObject({
|
||||
index: 0,
|
||||
linked: false,
|
||||
type: 'STRING'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
@@ -784,539 +756,3 @@ describe('Pre-remove vueNodeData drain', () => {
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager property triggers', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates Vue node data for LiteGraph property change events', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.collapsed',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.ghost',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.pinned',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 4
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: '#123456'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: '#abcdef'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 2
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'showAdvanced',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
newValue: [{ text: 'hot' }]
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
title: 'Renamed',
|
||||
hasErrors: true,
|
||||
flags: {
|
||||
collapsed: true,
|
||||
ghost: true,
|
||||
pinned: true
|
||||
},
|
||||
mode: 4,
|
||||
color: '#123456',
|
||||
bgcolor: '#abcdef',
|
||||
shape: 2,
|
||||
showAdvanced: true,
|
||||
badges: [{ text: 'hot' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes invalid property payloads to safe Vue node data', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 'invalid'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: false
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: 123
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 'round'
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
mode: 0,
|
||||
color: undefined,
|
||||
bgcolor: undefined,
|
||||
shape: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores property events for nodes the manager does not track', () => {
|
||||
const graph = new LGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: 'missing',
|
||||
property: 'title',
|
||||
newValue: 'ignored'
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(vueNodeData.has(toNodeId('missing'))).toBe(false)
|
||||
expect(vueNodeData.size).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores non-input slot link events and refreshes slot error metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined)
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
|
||||
const linkId: unknown = 123
|
||||
input.link = linkId as typeof input.link
|
||||
graph.trigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVueNodeData widget mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('normalizes widget callback values and redraws sibling widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const callback = vi.fn()
|
||||
const siblingTriggerDraw = vi.fn()
|
||||
node.addWidget('string', 'prompt', 'hello', callback)
|
||||
node.addCustomWidget(
|
||||
fromPartial<IBaseWidget>({
|
||||
name: 'sibling',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
triggerDraw: siblingTriggerDraw
|
||||
})
|
||||
)
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((widget) => widget.name === 'prompt')
|
||||
if (!widgetData?.callback) throw new Error('Missing widget callback')
|
||||
|
||||
widgetData.callback(null)
|
||||
expect(node.widgets![0].value).toBeUndefined()
|
||||
|
||||
widgetData.callback('text')
|
||||
expect(node.widgets![0].value).toBe('text')
|
||||
|
||||
widgetData.callback(3)
|
||||
expect(node.widgets![0].value).toBe(3)
|
||||
|
||||
widgetData.callback(true)
|
||||
expect(node.widgets![0].value).toBe(true)
|
||||
|
||||
const objectValue = { nested: true }
|
||||
widgetData.callback(objectValue)
|
||||
expect(node.widgets![0].value).toStrictEqual(objectValue)
|
||||
|
||||
const fileValues = [new File(['x'], 'x.txt')]
|
||||
widgetData.callback(fileValues)
|
||||
expect(node.widgets![0].value).toHaveLength(1)
|
||||
expect((node.widgets![0].value as File[])[0]).toBeInstanceOf(File)
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
widgetData.callback(Symbol('invalid'))
|
||||
|
||||
expect(node.widgets![0].value).toBeUndefined()
|
||||
expect(callback).toHaveBeenLastCalledWith(undefined, app.canvas, node)
|
||||
expect(siblingTriggerDraw).toHaveBeenCalled()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Invalid widget value type: symbol',
|
||||
expect.any(Symbol)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('extracts display, DOM, layout, tooltip, and duplicate widget metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addCustomWidget({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
value: 'a'
|
||||
} as IBaseWidget)
|
||||
const domWidget: unknown = {
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
value: 'b',
|
||||
advanced: true,
|
||||
element: document.createElement('input'),
|
||||
computeLayoutSize: () => ({ minWidth: 1, minHeight: 1 }),
|
||||
options: {
|
||||
canvasOnly: true,
|
||||
hidden: true,
|
||||
read_only: true
|
||||
},
|
||||
tooltip: 'Details'
|
||||
}
|
||||
node.addCustomWidget(domWidget as IBaseWidget)
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgets = vueNodeData.get(node.id)?.widgets
|
||||
|
||||
expect(widgets?.[0]?.options).toBeUndefined()
|
||||
expect(widgets?.[1]).toMatchObject({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
hasLayoutSize: true,
|
||||
isDOMWidget: true,
|
||||
tooltip: 'Details',
|
||||
options: {
|
||||
canvasOnly: true,
|
||||
advanced: true,
|
||||
hidden: true,
|
||||
read_only: true
|
||||
}
|
||||
})
|
||||
expect(widgets?.[0]?.widgetId).toBeDefined()
|
||||
expect(widgets?.[1]?.widgetId).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to safe widget data when a widget mapper throws', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('test')
|
||||
const badWidgetObj: unknown = {
|
||||
name: 'broken',
|
||||
type: 'custom',
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
}
|
||||
node.widgets = [badWidgetObj as IBaseWidget]
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.[0]).toEqual({ name: 'broken', type: 'custom' })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[safeWidgetMapper] Failed to map widget:',
|
||||
'broken',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to unknown widget data when a broken widget has no name or type', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('test')
|
||||
const badWidgetObj2: unknown = {
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
}
|
||||
node.widgets = [badWidgetObj2 as IBaseWidget]
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.[0]).toEqual({ name: 'unknown', type: 'text' })
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps custom widgets getter results in sync', () => {
|
||||
const node = new LGraphNode('test')
|
||||
let widgets = [
|
||||
{
|
||||
name: 'first',
|
||||
type: 'text',
|
||||
value: 'one',
|
||||
options: {}
|
||||
} as IBaseWidget
|
||||
]
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return widgets
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
expect(data.widgets?.map((widget) => widget.name)).toEqual(['first'])
|
||||
|
||||
widgets = [
|
||||
{
|
||||
name: 'second',
|
||||
type: 'text',
|
||||
value: 'two',
|
||||
options: {}
|
||||
} as IBaseWidget
|
||||
]
|
||||
|
||||
expect(node.widgets?.map((widget) => widget.name)).toEqual(['second'])
|
||||
expect(data.widgets?.map((widget) => widget.name)).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('treats undefined custom widget getter results as an empty widget list', () => {
|
||||
const node = new LGraphNode('test')
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return undefined
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.length).toBe(0)
|
||||
})
|
||||
|
||||
it('derives node type fallbacks and subgraph id from graph context', () => {
|
||||
const node = new LGraphNode('')
|
||||
node.type = ''
|
||||
Object.defineProperty(node, 'constructor', {
|
||||
value: { title: 'FallbackTitle', nodeData: { api_node: true } },
|
||||
configurable: true
|
||||
})
|
||||
node.graph = {
|
||||
id: 'subgraph-id',
|
||||
rootGraph: new LGraph()
|
||||
} as LGraph
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.type).toBe('FallbackTitle')
|
||||
expect(data.subgraphId).toBe('subgraph-id')
|
||||
expect(data.apiNode).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves flags when extracting Vue node data', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.flags = { collapsed: true, pinned: true }
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.flags).toEqual({ collapsed: true, pinned: true })
|
||||
})
|
||||
|
||||
it('keeps existing promoted widget state when mapping host widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'source.safetensors',
|
||||
() => undefined,
|
||||
{
|
||||
values: ['source.safetensors']
|
||||
}
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
const rootGraphSpy = vi
|
||||
.spyOn(app, 'rootGraph', 'get')
|
||||
.mockReturnValue(graph)
|
||||
|
||||
const id = subgraphNode.inputs[0].widgetId
|
||||
if (!id) throw new Error('Expected promoted input to have widgetId')
|
||||
const widgetStore = useWidgetValueStore()
|
||||
if (widgetStore.getWidget(id)) {
|
||||
widgetStore.setValue(id, 'existing.safetensors')
|
||||
} else {
|
||||
widgetStore.registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'existing.safetensors',
|
||||
options: {},
|
||||
label: 'Existing'
|
||||
})
|
||||
}
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(widgetStore.getWidget(id)?.value).toBe('existing.safetensors')
|
||||
rootGraphSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager lifecycle hooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('defers layout extraction until graph configuration completes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.title = 'Before'
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalAfterConfigured = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
node.onAfterGraphConfigured = originalAfterConfigured
|
||||
const originalWindowApp = window.app
|
||||
window.app = { configuringGraph: true } as Window['app']
|
||||
|
||||
try {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('Before')
|
||||
|
||||
node.title = 'After'
|
||||
node.onAfterGraphConfigured?.()
|
||||
|
||||
expect(originalAfterConfigured).toHaveBeenCalled()
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('After')
|
||||
} finally {
|
||||
window.app = originalWindowApp
|
||||
}
|
||||
})
|
||||
|
||||
it('chains original remove and trigger handlers, then restores them on cleanup', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
const originalOnTrigger = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
graph.onNodeRemoved = originalOnNodeRemoved
|
||||
graph.onTrigger = originalOnTrigger
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.remove(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(originalOnTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'node:property:changed' })
|
||||
)
|
||||
expect(originalOnNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(graph.onNodeRemoved).toBe(originalOnNodeRemoved)
|
||||
expect(graph.onTrigger).toBe(originalOnTrigger)
|
||||
})
|
||||
|
||||
it('cleans up to undefined when no original callbacks existed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
expect(manager.vueNodeData.has(node.id)).toBe(true)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getControlWidget', () => {
|
||||
it('normalizes linked control widget values and updates the source widget', () => {
|
||||
const linkedControl = {
|
||||
[IS_CONTROL_WIDGET]: true,
|
||||
value: 'fixed'
|
||||
}
|
||||
const widgetObj: unknown = { linkedWidgets: [linkedControl] }
|
||||
const widget = widgetObj as IBaseWidget
|
||||
|
||||
const control = getControlWidget(widget)
|
||||
|
||||
expect(control?.value).toBe('fixed')
|
||||
|
||||
control?.update('unexpected')
|
||||
|
||||
expect(linkedControl.value).toBe('randomize')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
|
||||
|
||||
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
|
||||
vi.hoisted(() => ({
|
||||
canvas: { setDirty: vi.fn() },
|
||||
captureCanvasState: vi.fn(),
|
||||
isLightTheme: { value: false },
|
||||
refreshCanvas: vi.fn(),
|
||||
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (k: string) => settings[k] })
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { changeTracker: { captureCanvasState } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas })
|
||||
}))
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({ refreshCanvas })
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [{ value: 1, localizedName: 'Box' }],
|
||||
colorOptions: [
|
||||
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
|
||||
],
|
||||
isLightTheme
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return withI18n(() => useGroupMenuOptions())
|
||||
}
|
||||
|
||||
function group(over: Record<string, unknown> = {}): LGraphGroup {
|
||||
return fromPartial<LGraphGroup>({
|
||||
recomputeInsideNodes: vi.fn(),
|
||||
resizeTo: vi.fn(),
|
||||
children: [],
|
||||
graph: { change: vi.fn() },
|
||||
nodes: [],
|
||||
...over
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
canvas.setDirty.mockReset()
|
||||
captureCanvasState.mockReset()
|
||||
isLightTheme.value = false
|
||||
refreshCanvas.mockReset()
|
||||
})
|
||||
|
||||
describe('useGroupMenuOptions', () => {
|
||||
it('fits a group to its nodes, resizing with the configured padding', () => {
|
||||
const g = group()
|
||||
setup().getFitGroupToNodesOption(g).action?.()
|
||||
|
||||
expect(g.recomputeInsideNodes).toHaveBeenCalled()
|
||||
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the fit action when recompute throws', () => {
|
||||
const g = group({
|
||||
recomputeInsideNodes: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
})
|
||||
setup().getFitGroupToNodesOption(g).action?.()
|
||||
|
||||
expect(g.resizeTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a shape to all group nodes via the shape submenu', () => {
|
||||
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
|
||||
const bump = vi.fn()
|
||||
const option = setup().getGroupShapeOptions(group({ nodes: [node] }), bump)
|
||||
option.submenu?.[0].action?.()
|
||||
|
||||
expect(node.shape).toBe(1)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles shape actions when a group has no nodes array', () => {
|
||||
const bump = vi.fn()
|
||||
setup()
|
||||
.getGroupShapeOptions(group({ nodes: undefined }), bump)
|
||||
.submenu?.[0].action?.()
|
||||
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a color to the group via the color submenu (dark theme)', () => {
|
||||
const g = group()
|
||||
const bump = vi.fn()
|
||||
setup().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect(g.color).toBe('#111')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a light-theme color to the group via the color submenu', () => {
|
||||
const g = group()
|
||||
const bump = vi.fn()
|
||||
isLightTheme.value = true
|
||||
setup().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect(g.color).toBe('#eee')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns no mode options for an empty group', () => {
|
||||
expect(setup().getGroupModeOptions(group(), vi.fn())).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no mode options when a group has no nodes array', () => {
|
||||
expect(
|
||||
setup().getGroupModeOptions(group({ nodes: undefined }), vi.fn())
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no mode options when recomputing group nodes fails', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const options = setup().getGroupModeOptions(
|
||||
group({
|
||||
recomputeInsideNodes: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}),
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
expect(options).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to recompute nodes in group for mode options:',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('builds mode options for uniform nodes and applies the new mode', () => {
|
||||
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
|
||||
const bump = vi.fn()
|
||||
const options = setup().getGroupModeOptions(group({ nodes: [node] }), bump)
|
||||
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
options[0].action?.()
|
||||
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are NEVER', () => {
|
||||
const options = setup().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are BYPASS', () => {
|
||||
const options = setup().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers all three modes when nodes have mixed modes', () => {
|
||||
const options = setup().getGroupModeOptions(
|
||||
group({
|
||||
nodes: [
|
||||
{ mode: LGraphEventMode.ALWAYS },
|
||||
{ mode: LGraphEventMode.NEVER }
|
||||
]
|
||||
}),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('offers all three modes when the uniform mode is unknown', () => {
|
||||
const options = setup().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: 999 }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,58 +1,24 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key.split('.').pop() ?? key
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return withI18n(() => useImageMenuOptions())
|
||||
}
|
||||
|
||||
function mockGetContext(
|
||||
ctx: Partial<CanvasRenderingContext2D>
|
||||
): HTMLCanvasElement['getContext'] {
|
||||
const fn: unknown = () => fromPartial<CanvasRenderingContext2D>(ctx)
|
||||
return fn as HTMLCanvasElement['getContext']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
const hadOriginalClipboard = 'clipboard' in navigator
|
||||
const originalClipboard = navigator.clipboard
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -61,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubClipboardItem() {
|
||||
vi.stubGlobal(
|
||||
'ClipboardItem',
|
||||
class ClipboardItemStub {
|
||||
constructor(public readonly items: Record<string, Blob>) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
@@ -88,29 +45,14 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
// Object.defineProperty on navigator isn't undone by unstubAllGlobals.
|
||||
if (hadOriginalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: originalClipboard,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard')
|
||||
}
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
it('includes Paste Image option when node supports paste', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -119,7 +61,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('excludes Paste Image option when node does not support paste', () => {
|
||||
const node = createImageNode({ pasteFiles: undefined })
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -128,24 +70,18 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('returns empty array when node has no images and no pasteFiles', () => {
|
||||
const node = createMockLGraphNode({ imgs: [] })
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
|
||||
expect(getImageMenuOptions(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when node image capabilities are absent', () => {
|
||||
const { getImageMenuOptions } = setup()
|
||||
|
||||
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only Paste Image when node has no images but supports paste', () => {
|
||||
const node = createMockLGraphNode({
|
||||
imgs: [],
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -154,7 +90,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('places Paste Image between Copy Image and Save Image', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const labels = options.map((o) => o.label)
|
||||
|
||||
@@ -168,7 +104,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('gives the Open in Mask Editor option the mask icon', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
|
||||
|
||||
@@ -177,7 +113,7 @@ describe('useImageMenuOptions', () => {
|
||||
|
||||
it('gives every image action option an icon so labels stay aligned', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
|
||||
expect(options.every((o) => !!o.icon)).toBe(true)
|
||||
@@ -199,7 +135,7 @@ describe('useImageMenuOptions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -216,7 +152,7 @@ describe('useImageMenuOptions', () => {
|
||||
const node = createImageNode()
|
||||
mockClipboard(fromPartial<Clipboard>({ read: undefined }))
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -237,7 +173,7 @@ describe('useImageMenuOptions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const pasteOption = options.find((o) => o.label === 'Paste Image')
|
||||
|
||||
@@ -246,213 +182,4 @@ describe('useImageMenuOptions', () => {
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('image actions', () => {
|
||||
it('opens the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const openOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Open Image'
|
||||
)
|
||||
openOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const saveOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Save Image'
|
||||
)
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open or save when the active image is missing', () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const options = getImageMenuOptions(node)
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run image actions when images are cleared after menu creation', async () => {
|
||||
const node = createImageNode()
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
const options = getImageMenuOptions(node)
|
||||
node.imgs = []
|
||||
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
await options.find((o) => o.label === 'Copy Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when the active image is missing', async () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs save failures for invalid image URLs', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
Object.defineProperty(node.imgs![0], 'src', {
|
||||
value: 'http://[',
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Save Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to save image:',
|
||||
expect.any(TypeError)
|
||||
)
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the selected image to clipboard', async () => {
|
||||
const node = createImageNode()
|
||||
const drawImage = vi.fn()
|
||||
const write = vi.fn().mockResolvedValue(undefined)
|
||||
stubClipboardItem()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
mockGetContext({ drawImage })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
|
||||
expect(write).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
items: { 'image/png': expect.any(Blob) }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not copy when canvas context is unavailable', async () => {
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() => null) as HTMLCanvasElement['getContext']
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when canvas blob creation fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
mockGetContext({ drawImage: vi.fn() })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when clipboard write is unavailable', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
mockClipboard(fromPartial<Clipboard>({ write: undefined }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
mockGetContext({ drawImage: vi.fn() })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Clipboard API not available')
|
||||
})
|
||||
|
||||
it('logs clipboard copy failures', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
stubClipboardItem()
|
||||
mockClipboard(
|
||||
fromPartial<Clipboard>({
|
||||
write: vi.fn().mockRejectedValue(new Error('blocked'))
|
||||
})
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
mockGetContext({ drawImage: vi.fn() })
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = setup()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to copy image to clipboard:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
isNodeOptionsOpen,
|
||||
registerNodeOptionsInstance,
|
||||
showNodeOptions,
|
||||
toggleNodeOptions,
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const {
|
||||
canvasState,
|
||||
extraWidgetOptions,
|
||||
imageOptions,
|
||||
nodeMenu,
|
||||
selectionMenu,
|
||||
selectionState
|
||||
} = vi.hoisted(() => ({
|
||||
canvasState: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
getNodeMenuOptions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
extraWidgetOptions: {
|
||||
value: [] as Array<{ content: string; callback?: () => void }>
|
||||
},
|
||||
imageOptions: {
|
||||
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
|
||||
},
|
||||
nodeMenu: {
|
||||
visualOptions: {
|
||||
value: [] as Array<{
|
||||
label: string
|
||||
hasSubmenu?: boolean
|
||||
submenu?: Array<{ label: string; action: () => void }>
|
||||
}>
|
||||
}
|
||||
},
|
||||
selectionMenu: {
|
||||
basicOptions: { value: [{ label: 'Copy' }] },
|
||||
multipleOptions: { value: [{ label: 'Align' }] },
|
||||
subgraphOptions: { value: [] as Array<{ label: string }> }
|
||||
},
|
||||
selectionState: {
|
||||
selectedItems: { value: [] as unknown[] },
|
||||
selectedNodes: { value: [] as unknown[] },
|
||||
canOpenNodeInfo: { value: false },
|
||||
openNodeInfo: vi.fn(() => true),
|
||||
hasSubgraphs: { value: false },
|
||||
hasImageNode: { value: false },
|
||||
hasOutputNodesSelected: { value: false },
|
||||
hasMultipleSelection: { value: false },
|
||||
computeSelectionFlags: vi.fn(() => ({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => selectionState
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasState
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
getExtraOptionsForWidget: () => extraWidgetOptions.value
|
||||
}))
|
||||
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
|
||||
useImageMenuOptions: () => ({
|
||||
getImageMenuOptions: () => imageOptions.value
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
|
||||
useNodeMenuOptions: () => ({
|
||||
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
|
||||
label: 'Node Info',
|
||||
action: openNodeInfo
|
||||
}),
|
||||
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
|
||||
getPinOption: (states: { pinned: boolean }) => ({
|
||||
label: states.pinned ? 'Unpin' : 'Pin'
|
||||
}),
|
||||
getBypassOption: () => ({ label: 'Bypass' }),
|
||||
getRunBranchOption: () => ({ label: 'Run Branch' })
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
|
||||
useGroupMenuOptions: () => ({
|
||||
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
|
||||
getGroupColorOptions: () => ({ label: 'Group Color' }),
|
||||
getGroupModeOptions: () => [{ label: 'Group Mode' }]
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
|
||||
useSelectionMenuOptions: () => ({
|
||||
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
|
||||
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
|
||||
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
|
||||
})
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerNodeOptionsInstance(null)
|
||||
// hoveredWidget is a module-level singleton; clear it via the same
|
||||
// public API that sets it so hover state can't leak across tests.
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
canvasState.canvas = undefined
|
||||
extraWidgetOptions.value = []
|
||||
imageOptions.value = []
|
||||
nodeMenu.visualOptions.value = []
|
||||
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
|
||||
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
|
||||
selectionMenu.subgraphOptions.value = []
|
||||
selectionState.selectedItems.value = []
|
||||
selectionState.selectedNodes.value = []
|
||||
selectionState.canOpenNodeInfo.value = false
|
||||
selectionState.hasSubgraphs.value = false
|
||||
selectionState.hasImageNode.value = false
|
||||
selectionState.hasOutputNodesSelected.value = false
|
||||
selectionState.hasMultipleSelection.value = false
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
|
||||
function labels() {
|
||||
return useMoreOptionsMenu()
|
||||
.menuOptions.value.map((o) => o.label)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
describe('node options popover instance', () => {
|
||||
it('reports closed when no instance is registered', () => {
|
||||
expect(isNodeOptionsOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects the registered instance open state and forwards toggle/show', () => {
|
||||
const toggle = vi.fn()
|
||||
const show = vi.fn()
|
||||
registerNodeOptionsInstance({
|
||||
toggle,
|
||||
show,
|
||||
hide: vi.fn(),
|
||||
isOpen: ref(true)
|
||||
})
|
||||
|
||||
expect(isNodeOptionsOpen()).toBe(true)
|
||||
toggleNodeOptions(new Event('click'))
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
expect(show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMoreOptionsMenu', () => {
|
||||
it('assembles a non-empty menu for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(labels()).toContain('Pin')
|
||||
})
|
||||
|
||||
it('includes run-branch and multiple-node options for output selections', () => {
|
||||
const nodes = [
|
||||
{ id: 1, widgets: [] },
|
||||
{ id: 2, widgets: [] }
|
||||
]
|
||||
selectionState.selectedItems.value = nodes
|
||||
selectionState.selectedNodes.value = nodes
|
||||
selectionState.hasOutputNodesSelected.value = true
|
||||
selectionState.hasMultipleSelection.value = true
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Run Branch')
|
||||
expect(menuLabels).toContain('Align')
|
||||
})
|
||||
|
||||
it('recomputes menu options after a manual bump reflects new selection flags', () => {
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
const { bump, menuOptions } = useMoreOptionsMenu()
|
||||
expect(menuOptions.value.map((o) => o.label)).toContain('Pin')
|
||||
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: true
|
||||
})
|
||||
bump()
|
||||
const labelsAfterBump = menuOptions.value.map((o) => o.label)
|
||||
expect(labelsAfterBump).toContain('Unpin')
|
||||
expect(labelsAfterBump).not.toContain('Pin')
|
||||
})
|
||||
|
||||
it('assembles group-context options for a single selected group', () => {
|
||||
const group = new LGraphGroup('Group')
|
||||
selectionState.selectedItems.value = [group]
|
||||
selectionState.selectedNodes.value = []
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Group Mode')
|
||||
expect(menuLabels).toContain('Fit')
|
||||
expect(menuLabels).toContain('Group Color')
|
||||
})
|
||||
|
||||
it('includes node info and visual options for a single node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.canOpenNodeInfo.value = true
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{ label: 'Shape', hasSubmenu: true, submenu: [] },
|
||||
{ label: 'Color', hasSubmenu: true, submenu: [] }
|
||||
]
|
||||
|
||||
const menu = useMoreOptionsMenu().menuOptions.value
|
||||
expect(menu.map((o) => o.label)).toEqual(
|
||||
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
|
||||
)
|
||||
menu.find((o) => o.label === 'Node Info')?.action?.()
|
||||
expect(selectionState.openNodeInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns only entries that have populated submenus', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{
|
||||
label: 'Shape',
|
||||
hasSubmenu: true,
|
||||
submenu: [{ label: 'Box', action: vi.fn() }]
|
||||
},
|
||||
{ label: 'Color', hasSubmenu: true }
|
||||
]
|
||||
|
||||
expect(
|
||||
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
|
||||
).toEqual(['Shape'])
|
||||
})
|
||||
|
||||
it('includes image menu options for a selected image node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.hasImageNode.value = true
|
||||
imageOptions.value = [{ label: 'Open Image' }]
|
||||
|
||||
expect(labels()).toContain('Open Image')
|
||||
})
|
||||
|
||||
it('merges LiteGraph menu options for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
const getNodeMenuOptions = vi.fn(() => [
|
||||
{ content: 'Extension Action', callback: vi.fn() }
|
||||
])
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = { getNodeMenuOptions }
|
||||
|
||||
expect(labels()).toContain('Extension Action')
|
||||
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('keeps Vue options when LiteGraph menu construction throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = {
|
||||
getNodeMenuOptions: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error getting LiteGraph menu items:',
|
||||
expect.any(Error)
|
||||
)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('adds hovered widget options to the selected node menu', () => {
|
||||
const node = { id: 1, widgets: [{ name: 'image' }] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
|
||||
|
||||
showNodeOptions(new MouseEvent('contextmenu'), 'image')
|
||||
|
||||
expect(labels()).toContain('Widget Extra')
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
|
||||
|
||||
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
|
||||
selection: { items: [] as unknown[] },
|
||||
refreshCanvas: vi.fn(),
|
||||
palette: { light_theme: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
get selectedItems() {
|
||||
return selection.items
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
get completedActivePalette() {
|
||||
return { light_theme: palette.light_theme }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({ refreshCanvas })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function colorable(bgcolor?: string) {
|
||||
return {
|
||||
setColorOption: vi.fn(),
|
||||
getColorOption: () => (bgcolor ? { bgcolor } : null)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
selection.items = []
|
||||
refreshCanvas.mockReset()
|
||||
palette.light_theme = false
|
||||
})
|
||||
|
||||
describe('useNodeCustomization', () => {
|
||||
it('exposes color and shape option lists', () => {
|
||||
const { colorOptions, shapeOptions } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
expect(colorOptions.length).toBeGreaterThan(1)
|
||||
expect(shapeOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reflects the active palette light-theme flag', () => {
|
||||
palette.light_theme = true
|
||||
expect(withI18n(() => useNodeCustomization()).isLightTheme.value).toBe(true)
|
||||
})
|
||||
|
||||
it('clears color on all colorable items for the no-color option', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
withI18n(() => useNodeCustomization()).applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a named color option to colorable items', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
const { colorOptions, applyColor } = withI18n(() => useNodeCustomization())
|
||||
const named = colorOptions.at(-1)!
|
||||
|
||||
applyColor(named)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledTimes(1)
|
||||
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips non-colorable items when applying colors', () => {
|
||||
const item = colorable()
|
||||
selection.items = [{}, item]
|
||||
|
||||
withI18n(() => useNodeCustomization()).applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current color for an empty selection', () => {
|
||||
expect(withI18n(() => useNodeCustomization()).getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null current color when no selected item is colorable', () => {
|
||||
selection.items = [{}]
|
||||
expect(withI18n(() => useNodeCustomization()).getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a recognized current color', () => {
|
||||
const { colorOptions, getCurrentColor } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
const named = colorOptions.at(-1)!
|
||||
selection.items = [colorable(named.value.dark)]
|
||||
|
||||
expect(getCurrentColor()?.name).toBe(named.name)
|
||||
})
|
||||
|
||||
it('falls back to the no-color option for an unrecognized current color', () => {
|
||||
selection.items = [colorable('#not-a-known-color')]
|
||||
const result = withI18n(() => useNodeCustomization()).getCurrentColor()
|
||||
expect(result?.name).toBe('noColor')
|
||||
})
|
||||
|
||||
it('no-ops shape changes when no graph nodes are selected', () => {
|
||||
selection.items = [colorable()]
|
||||
const { applyShape, shapeOptions } = withI18n(() => useNodeCustomization())
|
||||
applyShape(shapeOptions[0])
|
||||
expect(refreshCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current shape with no nodes selected', () => {
|
||||
expect(withI18n(() => useNodeCustomization()).getCurrentShape()).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a shape to selected graph nodes and refreshes', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
selection.items = [node]
|
||||
const { applyShape, shapeOptions } = withI18n(() => useNodeCustomization())
|
||||
const target = shapeOptions[0]
|
||||
|
||||
applyShape(target)
|
||||
|
||||
expect(node.shape).toBe(target.value)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the current shape of a selected node', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const { shapeOptions, getCurrentShape } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
node.shape = shapeOptions[0].value
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
|
||||
it('uses the default shape when a selected node has no shape', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
Object.defineProperty(node, 'shape', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
const { shapeOptions, getCurrentShape } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
|
||||
it('falls back to the default shape for an unknown node shape', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
Object.defineProperty(node, 'shape', {
|
||||
value: 999,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
const { shapeOptions, getCurrentShape } = withI18n(() =>
|
||||
useNodeCustomization()
|
||||
)
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
})
|
||||
@@ -1,221 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
|
||||
|
||||
const {
|
||||
canvas,
|
||||
toastAdd,
|
||||
captureCanvasState,
|
||||
updateSelectedItems,
|
||||
prompt,
|
||||
titleEditor,
|
||||
store
|
||||
} = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<unknown>(),
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
deleteSelected: vi.fn(),
|
||||
setDirty: vi.fn()
|
||||
},
|
||||
toastAdd: vi.fn(),
|
||||
captureCanvasState: vi.fn(),
|
||||
updateSelectedItems: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
titleEditor: { titleEditorTarget: null as unknown },
|
||||
store: { selectedItems: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { changeTracker: { captureCanvasState } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
updateSelectedItems,
|
||||
get selectedItems() {
|
||||
return store.selectedItems
|
||||
}
|
||||
}),
|
||||
useTitleEditorStore: () => titleEditor
|
||||
}))
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ prompt })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
canvas.selectedItems = new Set()
|
||||
canvas.copyToClipboard.mockReset()
|
||||
canvas.pasteFromClipboard.mockReset()
|
||||
canvas.deleteSelected.mockReset()
|
||||
canvas.setDirty.mockReset()
|
||||
toastAdd.mockReset()
|
||||
captureCanvasState.mockReset()
|
||||
updateSelectedItems.mockReset()
|
||||
prompt.mockReset()
|
||||
titleEditor.titleEditorTarget = null
|
||||
store.selectedItems = []
|
||||
})
|
||||
|
||||
describe('useSelectionOperations', () => {
|
||||
it('warns and does nothing when copying an empty selection', () => {
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('copies a non-empty selection and reports success', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('pastes from clipboard and captures canvas state', () => {
|
||||
useSelectionOperations().pasteSelection()
|
||||
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
|
||||
connectInputs: false
|
||||
})
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('duplicates by copy, clear, paste', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(canvas.selectedItems.size).toBe(0)
|
||||
expect(updateSelectedItems).toHaveBeenCalled()
|
||||
expect(canvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when duplicating nothing', () => {
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes a non-empty selection and marks the canvas dirty', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().deleteSelection()
|
||||
expect(canvas.deleteSelected).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when deleting nothing', () => {
|
||||
useSelectionOperations().deleteSelection()
|
||||
expect(canvas.deleteSelected).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a single node rename to the title editor', async () => {
|
||||
const node = new LGraphNode('Test')
|
||||
store.selectedItems = [node]
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(titleEditor.titleEditorTarget).toBe(node)
|
||||
expect(prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renames a single non-node item via the prompt dialog', async () => {
|
||||
const group = { title: 'Old' }
|
||||
store.selectedItems = [group]
|
||||
prompt.mockResolvedValue('New')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(group.title).toBe('New')
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
|
||||
const group = { title: 'Old' }
|
||||
store.selectedItems = [group]
|
||||
prompt.mockResolvedValue('Old')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(group.title).toBe('Old')
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not assign a title to a selected item without a title property', async () => {
|
||||
const item = {}
|
||||
store.selectedItems = [item]
|
||||
prompt.mockResolvedValue('New')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(item).toEqual({})
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('batch-renames multiple items with an indexed base name', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = { title: 'b' }
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('Item')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('Item 1')
|
||||
expect(b.title).toBe('Item 2')
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips untitled items during batch rename', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = {}
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('Item')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('Item 1')
|
||||
expect(b).toEqual({})
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = { title: 'b' }
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('a')
|
||||
expect(b.title).toBe('b')
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when renaming an empty selection', async () => {
|
||||
await useSelectionOperations().renameSelection()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import {
|
||||
isImageNode,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
@@ -22,9 +17,7 @@ import {
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isLGraphGroup: vi.fn(),
|
||||
isLoad3dNode: vi.fn()
|
||||
isImageNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
@@ -103,14 +96,6 @@ describe('useSelectionState', () => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'ImageNode'
|
||||
})
|
||||
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
|
||||
const typedItem = item as { isGroup?: boolean }
|
||||
return typedItem?.isGroup === true
|
||||
})
|
||||
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'Load3D'
|
||||
})
|
||||
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
|
||||
nodes.filter((n) => n.type === 'OutputNode')
|
||||
)
|
||||
@@ -150,21 +135,6 @@ describe('useSelectionState', () => {
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(false)
|
||||
})
|
||||
|
||||
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const graphNode = createMockLGraphNode({ id: 2 })
|
||||
const group = createMockPositionable({ id: 2000 })
|
||||
Object.assign(group, {
|
||||
isGroup: true,
|
||||
isNode: false,
|
||||
children: new Set([graphNode])
|
||||
})
|
||||
canvasStore.$state.selectedItems = [group]
|
||||
|
||||
const { hasGroupedNodesSelection } = useSelectionState()
|
||||
expect(hasGroupedNodesSelection.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
@@ -245,13 +215,6 @@ describe('useSelectionState', () => {
|
||||
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
|
||||
test('should compute default flags for an empty node selection', () => {
|
||||
expect(useSelectionState().computeSelectionFlags()).toEqual({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Info', () => {
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
|
||||
type MockReroute = {
|
||||
id: string
|
||||
pos: [number, number]
|
||||
parentId?: string | null
|
||||
linkIds: Set<string>
|
||||
}
|
||||
|
||||
type MockLink = {
|
||||
id: string
|
||||
origin_id: string
|
||||
origin_slot: number
|
||||
target_id: string
|
||||
target_slot: number
|
||||
}
|
||||
|
||||
type MockGraph = {
|
||||
_nodes: LGraphNode[]
|
||||
reroutes: Map<string, MockReroute>
|
||||
_links: Map<string, MockLink>
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
type MockCanvas = {
|
||||
graph?: MockGraph
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockWheneverCallbacks = vi.hoisted(() => ({
|
||||
values: [] as Array<() => void>
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <Args extends unknown[], Return>(
|
||||
fn: (...args: Args) => Return
|
||||
) => fn,
|
||||
whenever: (_source: () => boolean, callback: () => void) => {
|
||||
mockWheneverCallbacks.values.push(callback)
|
||||
return vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseGraphNodeManager = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
useGraphNodeManager: mockUseGraphNodeManager
|
||||
}))
|
||||
|
||||
const mockShouldRenderVueNodes = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: mockShouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCanvasStoreCanvas = vi.hoisted(() => ({
|
||||
value: undefined as MockCanvas | undefined
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: mockCanvasStoreCanvas.value
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCreateReroute = vi.hoisted(() => vi.fn())
|
||||
const mockCreateLink = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: () => ({
|
||||
createReroute: mockCreateReroute,
|
||||
createLink: mockCreateLink
|
||||
})
|
||||
}))
|
||||
|
||||
const mockInitializeFromLiteGraph = vi.hoisted(() => vi.fn())
|
||||
const mockClearAllSlotLayouts = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
initializeFromLiteGraph: mockInitializeFromLiteGraph,
|
||||
clearAllSlotLayouts: mockClearAllSlotLayouts
|
||||
}
|
||||
}))
|
||||
|
||||
const mockStartSync = vi.hoisted(() => vi.fn())
|
||||
const mockStopSync = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/sync/useLayoutSync', () => ({
|
||||
useLayoutSync: () => ({
|
||||
startSync: mockStartSync,
|
||||
stopSync: mockStopSync
|
||||
})
|
||||
}))
|
||||
|
||||
const mockComfyCanvas = vi.hoisted(() => ({
|
||||
value: undefined as MockCanvas | undefined
|
||||
}))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get canvas() {
|
||||
return mockComfyCanvas.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockManagerCleanup = vi.hoisted(() => vi.fn())
|
||||
|
||||
function createNode(id: number, overrides: object = {}): LGraphNode {
|
||||
return createMockLGraphNode({
|
||||
id: toNodeId(id),
|
||||
pos: [id * 10, id * 20],
|
||||
size: [100 + id, 200 + id],
|
||||
flags: { collapsed: false },
|
||||
arrange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createGraph(overrides: Partial<MockGraph> = {}): MockGraph {
|
||||
return {
|
||||
_nodes: [],
|
||||
reroutes: new Map(),
|
||||
_links: new Map(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLifecycle() {
|
||||
vi.resetModules()
|
||||
const { useVueNodeLifecycle } = await import('./useVueNodeLifecycle')
|
||||
return useVueNodeLifecycle()
|
||||
}
|
||||
|
||||
describe('useVueNodeLifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWheneverCallbacks.values = []
|
||||
mockShouldRenderVueNodes.value = false
|
||||
mockCanvasStoreCanvas.value = undefined
|
||||
mockComfyCanvas.value = undefined
|
||||
mockManagerCleanup.mockReset()
|
||||
mockUseGraphNodeManager.mockReset()
|
||||
mockUseGraphNodeManager.mockReturnValue({ cleanup: mockManagerCleanup })
|
||||
})
|
||||
|
||||
it('initializes the node manager from the active graph', async () => {
|
||||
const node = createNode(1)
|
||||
const graph = createGraph({
|
||||
_nodes: [node],
|
||||
reroutes: new Map([
|
||||
[
|
||||
'reroute-1',
|
||||
{
|
||||
id: 'reroute-1',
|
||||
pos: [12, 34],
|
||||
parentId: null,
|
||||
linkIds: new Set(['link-1'])
|
||||
}
|
||||
]
|
||||
]),
|
||||
_links: new Map([
|
||||
[
|
||||
'link-1',
|
||||
{
|
||||
id: 'link-1',
|
||||
origin_id: toNodeId(1),
|
||||
origin_slot: 0,
|
||||
target_id: toNodeId(2),
|
||||
target_slot: 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'link-2',
|
||||
{
|
||||
id: 'link-2',
|
||||
origin_id: UNASSIGNED_NODE_ID,
|
||||
origin_slot: 0,
|
||||
target_id: toNodeId(2),
|
||||
target_slot: 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'link-3',
|
||||
{
|
||||
id: 'link-3',
|
||||
origin_id: toNodeId(1),
|
||||
origin_slot: 0,
|
||||
target_id: UNASSIGNED_NODE_ID,
|
||||
target_slot: 1
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
mockCanvasStoreCanvas.value = canvas
|
||||
mockShouldRenderVueNodes.value = true
|
||||
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
|
||||
expect(lifecycle.nodeManager.value).toEqual({
|
||||
cleanup: mockManagerCleanup
|
||||
})
|
||||
expect(mockInitializeFromLiteGraph).toHaveBeenCalledWith([
|
||||
{
|
||||
id: toNodeId(1),
|
||||
pos: [10, 20],
|
||||
size: [101, 201]
|
||||
}
|
||||
])
|
||||
expect(mockCreateReroute).toHaveBeenCalledWith(
|
||||
'reroute-1',
|
||||
{ x: 12, y: 34 },
|
||||
undefined,
|
||||
['link-1']
|
||||
)
|
||||
expect(mockCreateLink).toHaveBeenCalledOnce()
|
||||
expect(mockCreateLink).toHaveBeenCalledWith(
|
||||
'link-1',
|
||||
toNodeId(1),
|
||||
0,
|
||||
toNodeId(2),
|
||||
1
|
||||
)
|
||||
expect(mockStartSync).toHaveBeenCalledWith(canvas)
|
||||
})
|
||||
|
||||
it('does not initialize without an active graph', async () => {
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.initializeNodeManager()
|
||||
|
||||
expect(mockUseGraphNodeManager).not.toHaveBeenCalled()
|
||||
expect(mockStartSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops sync and tolerates manager cleanup errors', async () => {
|
||||
mockManagerCleanup.mockImplementation(() => {
|
||||
throw new Error('cleanup failed')
|
||||
})
|
||||
mockComfyCanvas.value = {
|
||||
graph: createGraph(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
expect(() => lifecycle.disposeNodeManagerAndSyncs()).not.toThrow()
|
||||
|
||||
expect(mockStopSync).toHaveBeenCalled()
|
||||
expect(lifecycle.nodeManager.value).toBeNull()
|
||||
})
|
||||
|
||||
it('arranges legacy nodes when the Vue node mode is disabled', async () => {
|
||||
const arrangeVisible = vi.fn()
|
||||
const arrangeThrowing = vi.fn(() => {
|
||||
throw new Error('not ready')
|
||||
})
|
||||
const graph = createGraph({
|
||||
_nodes: [
|
||||
createNode(1, { arrange: arrangeVisible }),
|
||||
createNode(2, { flags: { collapsed: true }, arrange: vi.fn() }),
|
||||
createNode(3, { arrange: arrangeThrowing })
|
||||
]
|
||||
})
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
mockShouldRenderVueNodes.value = true
|
||||
await loadLifecycle()
|
||||
|
||||
mockWheneverCallbacks.values[0]()
|
||||
|
||||
expect(arrangeVisible).toHaveBeenCalled()
|
||||
expect(arrangeThrowing).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('marks the canvas dirty when disabling without a graph', async () => {
|
||||
const canvas = { setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
await loadLifecycle()
|
||||
|
||||
mockWheneverCallbacks.values[0]()
|
||||
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('initializes on the first node added to an empty graph', async () => {
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const graph = createGraph({ onNodeAdded: originalOnNodeAdded })
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
const node = createNode(1)
|
||||
const lifecycle = await loadLifecycle()
|
||||
mockComfyCanvas.value = canvas
|
||||
|
||||
lifecycle.setupEmptyGraphListener()
|
||||
graph.onNodeAdded?.(node)
|
||||
|
||||
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not replace onNodeAdded when the empty-graph guard fails', async () => {
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const graph = createGraph({
|
||||
_nodes: [createNode(1)],
|
||||
onNodeAdded: originalOnNodeAdded
|
||||
})
|
||||
mockComfyCanvas.value = { graph, setDirty: vi.fn() }
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.setupEmptyGraphListener()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
})
|
||||
|
||||
it('cleans up the node manager on unmount', async () => {
|
||||
mockComfyCanvas.value = {
|
||||
graph: createGraph(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.cleanup()
|
||||
lifecycle.cleanup()
|
||||
|
||||
expect(mockManagerCleanup).toHaveBeenCalledOnce()
|
||||
expect(lifecycle.nodeManager.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,8 @@ const mockStore = reactive({
|
||||
gpuTexturesNeedRecreation: false,
|
||||
gpuTextureWidth: 0,
|
||||
gpuTextureHeight: 0,
|
||||
pendingGPUMaskData: null as Uint8Array | null,
|
||||
pendingGPURgbData: null as Uint8Array | null,
|
||||
pendingGPUMaskData: null as null,
|
||||
pendingGPURgbData: null as null,
|
||||
brushSettings: {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
@@ -42,143 +42,18 @@ vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
import { tgpu } from 'typegpu'
|
||||
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { resetDirtyRect } from './brushDrawingUtils'
|
||||
import { useGPUResources } from './useGPUResources'
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
const hadOriginalNavigatorGpu = 'gpu' in navigator
|
||||
const originalNavigatorGpu = (navigator as { gpu?: unknown }).gpu
|
||||
|
||||
function setup() {
|
||||
scope = effectScope()
|
||||
return scope.run(() => useGPUResources())!
|
||||
}
|
||||
|
||||
class TestImageData {
|
||||
data: Uint8ClampedArray
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
||||
this.data = data
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTexture(): GPUTexture {
|
||||
const obj: unknown = {
|
||||
createView: vi.fn(() => ({})),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
return obj as GPUTexture
|
||||
}
|
||||
|
||||
function createMockBuffer(byteLength = 16): GPUBuffer {
|
||||
const obj: unknown = {
|
||||
destroy: vi.fn(),
|
||||
mapAsync: vi.fn().mockResolvedValue(undefined),
|
||||
getMappedRange: vi.fn(() => new Uint8Array(byteLength).buffer),
|
||||
unmap: vi.fn()
|
||||
}
|
||||
return obj as GPUBuffer
|
||||
}
|
||||
|
||||
function createMockDevice(): GPUDevice {
|
||||
const obj: unknown = {
|
||||
limits: {},
|
||||
queue: {
|
||||
writeTexture: vi.fn(),
|
||||
submit: vi.fn()
|
||||
},
|
||||
createTexture: vi.fn(() => createMockTexture()),
|
||||
createBuffer: vi.fn(() => createMockBuffer()),
|
||||
createCommandEncoder: vi.fn(() => ({
|
||||
copyBufferToBuffer: vi.fn(),
|
||||
finish: vi.fn(() => ({}))
|
||||
}))
|
||||
}
|
||||
return obj as GPUDevice
|
||||
}
|
||||
|
||||
function createMockRenderer() {
|
||||
return {
|
||||
destroy: vi.fn(),
|
||||
prepareStroke: vi.fn(),
|
||||
clearPreview: vi.fn(),
|
||||
compositeStroke: vi.fn(),
|
||||
prepareReadback: vi.fn(),
|
||||
renderStrokeToAccumulator: vi.fn(),
|
||||
blitToCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mockGpuBrushRenderer(renderer: ReturnType<typeof createMockRenderer>) {
|
||||
vi.mocked(GPUBrushRenderer).mockImplementation(
|
||||
function GPUBrushRendererMock() {
|
||||
const r: unknown = renderer
|
||||
return r as GPUBrushRenderer
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createCanvasContext(
|
||||
width: number,
|
||||
height: number
|
||||
): CanvasRenderingContext2D {
|
||||
const obj: unknown = {
|
||||
globalCompositeOperation: 'source-over',
|
||||
getImageData: vi.fn(
|
||||
() =>
|
||||
new ImageData(new Uint8ClampedArray(width * height * 4), width, height)
|
||||
),
|
||||
putImageData: vi.fn()
|
||||
}
|
||||
return obj as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function setReadyCanvases(width = 2, height = 2) {
|
||||
const maskCanvas = document.createElement('canvas')
|
||||
maskCanvas.width = width
|
||||
maskCanvas.height = height
|
||||
const rgbCanvas = document.createElement('canvas')
|
||||
rgbCanvas.width = width
|
||||
rgbCanvas.height = height
|
||||
|
||||
mockStore.maskCanvas = maskCanvas
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
mockStore.maskCtx = createCanvasContext(width, height)
|
||||
mockStore.rgbCtx = createCanvasContext(width, height)
|
||||
}
|
||||
|
||||
function installGpuGlobals() {
|
||||
vi.stubGlobal('GPUTextureUsage', {
|
||||
TEXTURE_BINDING: 1,
|
||||
STORAGE_BINDING: 2,
|
||||
RENDER_ATTACHMENT: 4,
|
||||
COPY_DST: 8,
|
||||
COPY_SRC: 16
|
||||
})
|
||||
vi.stubGlobal('GPUBufferUsage', {
|
||||
STORAGE: 1,
|
||||
COPY_SRC: 2,
|
||||
COPY_DST: 4,
|
||||
MAP_READ: 8
|
||||
})
|
||||
vi.stubGlobal('GPUMapMode', { READ: 1 })
|
||||
vi.stubGlobal('ImageData', TestImageData)
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
value: { getPreferredCanvasFormat: vi.fn(() => 'rgba8unorm') },
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installGpuGlobals()
|
||||
mockStore.tgpuRoot = null
|
||||
mockStore.maskCanvas = null
|
||||
mockStore.rgbCanvas = null
|
||||
@@ -187,37 +62,11 @@ beforeEach(() => {
|
||||
mockStore.clearTrigger = 0
|
||||
mockStore.canvasHistory.currentStateIndex = 0
|
||||
mockStore.gpuTexturesNeedRecreation = false
|
||||
mockStore.gpuTextureWidth = 0
|
||||
mockStore.gpuTextureHeight = 0
|
||||
mockStore.pendingGPUMaskData = null
|
||||
mockStore.pendingGPURgbData = null
|
||||
mockStore.activeLayer = 'mask'
|
||||
mockStore.currentTool = 'pen'
|
||||
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
||||
mockStore.rgbColor = '#FF0000'
|
||||
mockStore.brushSettings = {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
opacity: 1,
|
||||
stepSize: 5,
|
||||
type: 'arc'
|
||||
}
|
||||
vi.mocked(tgpu.init).mockRejectedValue(new Error('WebGPU not supported'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
vi.unstubAllGlobals()
|
||||
// Object.defineProperty on navigator isn't undone by unstubAllGlobals.
|
||||
if (hadOriginalNavigatorGpu) {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
value: originalNavigatorGpu,
|
||||
configurable: true
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'gpu')
|
||||
}
|
||||
})
|
||||
|
||||
describe('initial reactive state', () => {
|
||||
@@ -282,34 +131,6 @@ describe('initGPUResources', () => {
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('handles non-error TypeGPU initialisation failures', async () => {
|
||||
vi.mocked(tgpu.init).mockRejectedValueOnce('WebGPU unavailable')
|
||||
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('initializes renderer when a root and canvas contexts are ready', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
mockStore.tgpuRoot = {
|
||||
device,
|
||||
destroy: vi.fn()
|
||||
}
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
expect(hasRenderer.value).toBe(true)
|
||||
expect(device.createTexture).toHaveBeenCalledTimes(2)
|
||||
expect(device.queue.writeTexture).toHaveBeenCalledTimes(2)
|
||||
expect(GPUBrushRenderer).toHaveBeenCalledWith(device, 'rgba8unorm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyGpuToCanvas', () => {
|
||||
@@ -353,18 +174,6 @@ describe('initGPUResources with pre-existing tgpuRoot', () => {
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('texture recreation watcher returns early when mask canvas is missing', async () => {
|
||||
const device = createMockDevice()
|
||||
const { initGPUResources } = setup()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
|
||||
await initGPUResources()
|
||||
mockStore.gpuTexturesNeedRecreation = true
|
||||
await nextTick()
|
||||
|
||||
expect(device.createTexture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initPreviewCanvas', () => {
|
||||
@@ -373,47 +182,6 @@ describe('initPreviewCanvas', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
expect(() => initPreviewCanvas(canvas)).not.toThrow()
|
||||
})
|
||||
|
||||
it('returns early when a WebGPU canvas context is unavailable', async () => {
|
||||
const device = createMockDevice()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(createMockRenderer())
|
||||
|
||||
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
|
||||
await initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', { value: vi.fn(() => null) })
|
||||
|
||||
initPreviewCanvas(canvas)
|
||||
|
||||
expect(previewCanvas.value).toBeNull()
|
||||
})
|
||||
|
||||
it('stores the preview canvas when a WebGPU context is available', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const previewContext = { configure: vi.fn() }
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
|
||||
await initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', {
|
||||
value: vi.fn(() => previewContext)
|
||||
})
|
||||
|
||||
initPreviewCanvas(canvas)
|
||||
|
||||
expect(previewContext.configure).toHaveBeenCalledWith({
|
||||
device,
|
||||
format: 'rgba8unorm',
|
||||
alphaMode: 'premultiplied'
|
||||
})
|
||||
expect(previewCanvas.value).toBe(canvas)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpuDrawPoint', () => {
|
||||
@@ -421,70 +189,4 @@ describe('gpuDrawPoint', () => {
|
||||
const { gpuDrawPoint } = setup()
|
||||
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates renderer operations when GPU resources are initialized', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const previewContext = { configure: vi.fn() }
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const resources = setup()
|
||||
await resources.initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', {
|
||||
value: vi.fn(() => previewContext)
|
||||
})
|
||||
resources.initPreviewCanvas(canvas)
|
||||
|
||||
resources.prepareStroke()
|
||||
resources.clearPreview()
|
||||
resources.compositeStroke(false, false)
|
||||
resources.gpuRender([{ x: 1, y: 1 }])
|
||||
await resources.gpuDrawPoint({ x: 1, y: 1 })
|
||||
|
||||
expect(renderer.prepareStroke).toHaveBeenCalledWith(2, 2)
|
||||
expect(renderer.clearPreview).toHaveBeenCalledWith(previewContext)
|
||||
expect(renderer.compositeStroke).toHaveBeenCalled()
|
||||
expect(renderer.renderStrokeToAccumulator).toHaveBeenCalled()
|
||||
expect(renderer.blitToCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies initialized GPU readback data to canvases', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const resources = setup()
|
||||
await resources.initGPUResources()
|
||||
const result = await resources.copyGpuToCanvas()
|
||||
|
||||
expect(result.maskData.width).toBe(2)
|
||||
expect(result.rgbData.height).toBe(2)
|
||||
expect(renderer.prepareReadback).toHaveBeenCalledTimes(2)
|
||||
expect(mockStore.maskCtx?.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.rgbCtx?.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('destroys initialized GPU resources and root state', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const root = { device, destroy: vi.fn() }
|
||||
mockStore.tgpuRoot = root
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, destroy, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
destroy()
|
||||
|
||||
expect(renderer.destroy).toHaveBeenCalled()
|
||||
expect(root.destroy).toHaveBeenCalled()
|
||||
expect(mockStore.tgpuRoot).toBeNull()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
extractWidgetStringValue,
|
||||
useMaskEditorLoader
|
||||
} from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface MockInputData {
|
||||
baseLayer: { url: string }
|
||||
maskLayer: { url: string }
|
||||
paintLayer?: { url: string }
|
||||
sourceRef: { filename: string; subfolder?: string; type?: string }
|
||||
nodeId: unknown
|
||||
}
|
||||
|
||||
const mockDataStore = vi.hoisted(() => ({
|
||||
inputData: undefined as unknown,
|
||||
sourceNode: undefined as unknown,
|
||||
setLoading: vi.fn()
|
||||
}))
|
||||
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
getNodeOutputs: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCloudState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: () => mockDataStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => mockNodeOutputStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockCloudState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `http://comfy.test${path}`),
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => '&preview=png'),
|
||||
getRandParam: vi.fn(() => '&rand=1')
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockImageClass(handler: 'onload' | 'onerror') {
|
||||
return class {
|
||||
crossOrigin = ''
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
private imageSrc = ''
|
||||
|
||||
get src() {
|
||||
return this.imageSrc
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.imageSrc = value
|
||||
queueMicrotask(() => this[handler]?.())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MockImage = createMockImageClass('onload')
|
||||
|
||||
function makeNode(overrides: object = {}): LGraphNode {
|
||||
const imgObj: unknown = {
|
||||
src: 'http://images.test/render.png?filename=render.png'
|
||||
}
|
||||
const node: unknown = {
|
||||
id: 42,
|
||||
imgs: [imgObj as HTMLImageElement],
|
||||
imageIndex: 0,
|
||||
...overrides
|
||||
}
|
||||
return node as LGraphNode
|
||||
}
|
||||
|
||||
function getInputData(): MockInputData {
|
||||
return mockDataStore.inputData as MockInputData
|
||||
}
|
||||
|
||||
describe('extractWidgetStringValue', () => {
|
||||
it('extracts strings and filename objects', () => {
|
||||
expect(extractWidgetStringValue('image.png')).toBe('image.png')
|
||||
expect(extractWidgetStringValue({ filename: 'object.png' })).toBe(
|
||||
'object.png'
|
||||
)
|
||||
expect(extractWidgetStringValue({ filename: 123 })).toBeUndefined()
|
||||
expect(extractWidgetStringValue(null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMaskEditorLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
vi.mocked(api.fetchApi).mockReset()
|
||||
mockDataStore.inputData = undefined
|
||||
mockDataStore.sourceNode = undefined
|
||||
mockDataStore.setLoading.mockClear()
|
||||
mockNodeOutputStore.getNodeOutputs.mockReset()
|
||||
mockCloudState.isCloud = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads base and mask layers from a node image reference', async () => {
|
||||
const node = makeNode({
|
||||
images: [
|
||||
{
|
||||
filename: 'node-output.png',
|
||||
subfolder: 'outputs',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(false)
|
||||
expect(mockDataStore.sourceNode).toBe(node)
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
nodeId: 42,
|
||||
sourceRef: {
|
||||
filename: 'node-output.png',
|
||||
subfolder: 'outputs',
|
||||
type: 'output'
|
||||
},
|
||||
paintLayer: undefined
|
||||
})
|
||||
expect(getInputData().baseLayer.url).toContain('channel=rgb')
|
||||
expect(getInputData().maskLayer.url).toContain('channel=a')
|
||||
})
|
||||
|
||||
it('uses a concrete image widget value instead of a stale node image', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'stale.png', type: 'output', subfolder: '' }],
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'clipspace/current.png [input]'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'current.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().baseLayer.url).toContain('filename=current.png')
|
||||
})
|
||||
|
||||
it('keeps internal widget references from replacing the node image', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'real-output.png', type: 'output' }],
|
||||
widgets: [{ name: 'image', value: '$35-0' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('real-output.png')
|
||||
})
|
||||
|
||||
it('loads image references from node output store data', async () => {
|
||||
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
|
||||
images: [
|
||||
{
|
||||
filename: 'store-output.png',
|
||||
subfolder: 'store',
|
||||
type: 'temp'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'store-output.png',
|
||||
subfolder: 'store',
|
||||
type: 'temp'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current non-data preview image when no image reference exists', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imageIndex: 1,
|
||||
imgs: [
|
||||
{ src: 'http://images.test/first.png?filename=first.png' },
|
||||
{ src: '/view?filename=second.png&type=input' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'second.png',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses cloud mask layer metadata when available', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
painted_masked: 'painted-masked.png',
|
||||
painted: 'painted.png',
|
||||
paint: 'paint.png'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/files/mask-layers?filename=cloud.png'
|
||||
)
|
||||
expect(getInputData().sourceRef.filename).toBe('painted-masked.png')
|
||||
expect(getInputData().paintLayer?.url).toContain('filename=paint.png')
|
||||
})
|
||||
|
||||
it('loads clipspace layer filenames from painted-masked images', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [
|
||||
{
|
||||
filename: 'clipspace-painted-masked-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'clipspace-mask-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().paintLayer?.url).toContain(
|
||||
'filename=clipspace-paint-123.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses painted cloud metadata when painted-masked metadata is absent', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
new Response(JSON.stringify({ painted: 'painted-only.png' }))
|
||||
)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('painted-only.png')
|
||||
expect(getInputData().paintLayer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the node image when cloud mask metadata is unavailable', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false
|
||||
} as Response)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('cloud.png')
|
||||
expect(getInputData().paintLayer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the node image when cloud mask metadata lookup rejects', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('offline'))
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('cloud.png')
|
||||
})
|
||||
|
||||
it('loads widget filenames without explicit folder metadata as inputs', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'stale.png', type: 'output' }],
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'plain.png'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'plain.png',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().sourceRef.subfolder).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces validation failures and clears loading state', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(makeNode({ imgs: [], images: [] }))
|
||||
).rejects.toThrow('Node has no images')
|
||||
|
||||
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
'Node has no images'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces null node validation failures', async () => {
|
||||
const nullNode: unknown = null
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(nullNode as LGraphNode)
|
||||
).rejects.toThrow('Node is null or undefined')
|
||||
})
|
||||
|
||||
it('surfaces missing output filenames', async () => {
|
||||
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
|
||||
images: [
|
||||
{
|
||||
filename: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('nodeOutputStore image missing filename')
|
||||
})
|
||||
|
||||
it('rejects data previews without output metadata', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Unable to get image URL from node')
|
||||
})
|
||||
|
||||
it('rejects preview URLs without filename metadata', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: '/view?type=input' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Invalid image URL: /view?type=input')
|
||||
})
|
||||
|
||||
it('propagates image load failures', async () => {
|
||||
vi.stubGlobal('Image', createMockImageClass('onerror'))
|
||||
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'broken.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Failed to load image:')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -98,7 +97,7 @@ describe('useMaskEditorSaver', () => {
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
|
||||
const nodeObj: unknown = {
|
||||
mockNode = fromAny<LGraphNode, unknown>({
|
||||
id: 42,
|
||||
type: 'LoadImage',
|
||||
images: [],
|
||||
@@ -109,8 +108,7 @@ describe('useMaskEditorSaver', () => {
|
||||
widgets_values: ['original.png [input]'],
|
||||
properties: { image: 'original.png [input]' },
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
mockNode = nodeObj as LGraphNode
|
||||
})
|
||||
|
||||
mockDataStore.sourceNode = mockNode
|
||||
mockDataStore.inputData = {
|
||||
@@ -137,7 +135,8 @@ describe('useMaskEditorSaver', () => {
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName === 'canvas') return createMockCanvas()
|
||||
if (tagName === 'canvas')
|
||||
return fromAny<HTMLCanvasElement, unknown>(createMockCanvas())
|
||||
return originalCreateElement(tagName, options)
|
||||
}
|
||||
)
|
||||
@@ -202,112 +201,4 @@ describe('useMaskEditorSaver', () => {
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws before saving when the source node is missing', async () => {
|
||||
mockDataStore.sourceNode = null
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('No source node or input data')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws before saving when the input data is missing', async () => {
|
||||
mockDataStore.inputData = null
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('No source node or input data')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when canvases are not initialized', async () => {
|
||||
mockEditorStore.maskCanvas = null
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('Canvas not initialized')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorSaver] Save failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('reports upload failures with the response body', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
text: () => Promise.resolve('too large')
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(
|
||||
/Failed to upload clipspace-mask-.*: too large/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports upload failures when the response body cannot be read', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.reject(new Error('body unavailable'))
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(/Failed to upload .+ \(500\)/)
|
||||
})
|
||||
|
||||
it('reports invalid upload JSON responses', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.reject(new Error('bad json'))
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(/Invalid upload response.*bad json/)
|
||||
})
|
||||
|
||||
it('reports upload responses without a name', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ subfolder: 'clipspace', type: 'input' })
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(
|
||||
"Upload response missing 'name' for clipspace-mask-"
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults missing upload ref fields and skips missing image widget state', async () => {
|
||||
mockNode.widgets = [
|
||||
fromPartial<IBaseWidget>({ name: 'other', value: 'unchanged' })
|
||||
]
|
||||
mockNode.widgets_values = ['unchanged']
|
||||
const noProperties: unknown = undefined
|
||||
const noGraph: unknown = undefined
|
||||
mockNode.properties = noProperties as LGraphNode['properties']
|
||||
mockNode.graph = noGraph as LGraphNode['graph']
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png' })
|
||||
} as Response)
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
await save()
|
||||
|
||||
expect(mockNode.images).toEqual([
|
||||
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
expect(mockNode.widgets_values).toEqual(['unchanged'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -41,7 +40,7 @@ vi.mock('@/stores/maskEditorStore', () => ({
|
||||
}))
|
||||
|
||||
function createMockElement(width = 1200, height = 800): HTMLElement {
|
||||
return fromPartial<HTMLElement>({
|
||||
return {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
style: {} as CSSStyleDeclaration,
|
||||
@@ -54,11 +53,11 @@ function createMockElement(width = 1200, height = 800): HTMLElement {
|
||||
right: width,
|
||||
bottom: height
|
||||
}) as DOMRect
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
return fromPartial<HTMLCanvasElement>({
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
clientWidth: width,
|
||||
@@ -73,7 +72,7 @@ function createMockCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
right: width,
|
||||
bottom: height
|
||||
}) as DOMRect
|
||||
})
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
function createMockImage(width: number, height: number): HTMLImageElement {
|
||||
@@ -82,15 +81,17 @@ function createMockImage(width: number, height: number): HTMLImageElement {
|
||||
|
||||
function createTouchList(...points: { x: number; y: number }[]): TouchList {
|
||||
const touches = points.map((p) => ({ clientX: p.x, clientY: p.y }) as Touch)
|
||||
const obj: unknown = Object.assign(touches, {
|
||||
return Object.assign(touches, {
|
||||
length: touches.length,
|
||||
item: (i: number) => touches[i]
|
||||
})
|
||||
return obj as TouchList
|
||||
}) as unknown as TouchList
|
||||
}
|
||||
|
||||
function createTouchEvent(touches: TouchList): TouchEvent {
|
||||
return fromPartial<TouchEvent>({ touches, preventDefault: vi.fn() })
|
||||
return {
|
||||
touches,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as TouchEvent
|
||||
}
|
||||
|
||||
async function initComposable() {
|
||||
@@ -99,7 +100,7 @@ async function initComposable() {
|
||||
const root = createMockElement()
|
||||
const container = createMockElement()
|
||||
const canvas = createMockCanvas(800, 600)
|
||||
mockStore.canvasContainer = container
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
mockStore.maskCanvas = canvas
|
||||
await pz.initializeCanvasPanZoom(img, root)
|
||||
vi.clearAllMocks()
|
||||
@@ -128,7 +129,7 @@ describe('usePanAndZoom', () => {
|
||||
it('sets zoom and pan on the store', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const container = createMockElement()
|
||||
mockStore.canvasContainer = container
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
@@ -144,7 +145,7 @@ describe('usePanAndZoom', () => {
|
||||
|
||||
it('accounts for panel widths via setPanOffset', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
|
||||
const toolPanel = createMockElement()
|
||||
vi.spyOn(toolPanel, 'getBoundingClientRect').mockReturnValue({
|
||||
@@ -169,7 +170,7 @@ describe('usePanAndZoom', () => {
|
||||
it('syncs rgbCanvas dimensions when they differ', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const rgbCanvas = createMockCanvas(400, 300)
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
@@ -180,50 +181,6 @@ describe('usePanAndZoom', () => {
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
})
|
||||
|
||||
it('returns before publishing pan when the canvas container is unavailable', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps rgbCanvas dimensions when they already match the image', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const rgbCanvas = createMockCanvas(800, 600)
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
expect(rgbCanvas.style.width).not.toBe('')
|
||||
})
|
||||
|
||||
it('can be initialized again without replacing the current image reference', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.canvasContainer = createMockElement()
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(400, 300),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePanStart / handlePanMove', () => {
|
||||
@@ -323,7 +280,7 @@ describe('usePanAndZoom', () => {
|
||||
it('returns early when maskCanvas is null', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const container = createMockElement()
|
||||
mockStore.canvasContainer = container
|
||||
mockStore.canvasContainer = container as unknown as HTMLElement
|
||||
mockStore.maskCanvas = null
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
@@ -388,13 +345,6 @@ describe('usePanAndZoom', () => {
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts touch starts without active touches', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList()))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.canvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores touch when pen pointer is active', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
@@ -451,49 +401,6 @@ describe('usePanAndZoom', () => {
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns from pinch zoom when the mask canvas is unavailable', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the cached mask canvas for consecutive pinch moves', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 140, y: 300 }, { x: 460, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touch move is ignored when pen is active', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
@@ -511,26 +418,6 @@ describe('usePanAndZoom', () => {
|
||||
pz.handleTouchEnd(event)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pinch zooming when all touches end', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
pz.handleTouchEnd(createTouchEvent(createTouchList()))
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pen pointer management', () => {
|
||||
|
||||
@@ -73,46 +73,4 @@ describe('useNodeAnimatedImage', () => {
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when a node has no images or widgets', () => {
|
||||
const { showAnimatedPreview, removeAnimatedPreview } =
|
||||
useNodeAnimatedImage()
|
||||
const noImageNode = createMockMediaNode({ imgs: [] })
|
||||
const noWidgetNode = Object.assign(
|
||||
createMockMediaNode({ imgs: [document.createElement('img')] }),
|
||||
{ widgets: undefined }
|
||||
)
|
||||
|
||||
showAnimatedPreview(noImageNode)
|
||||
showAnimatedPreview(noWidgetNode)
|
||||
removeAnimatedPreview(noWidgetNode)
|
||||
|
||||
expect(noImageNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('replaces the image in an existing preview widget', () => {
|
||||
const { node, showAnimatedPreview } = setup()
|
||||
const firstWidget = node.widgets[0]
|
||||
const nextImage = document.createElement('img')
|
||||
node.imgs = [nextImage]
|
||||
|
||||
showAnimatedPreview(node)
|
||||
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
expect(node.widgets[0]).toBe(firstWidget)
|
||||
expect(firstWidget.element.firstChild).toBe(nextImage)
|
||||
})
|
||||
|
||||
it('leaves an existing non-DOM preview widget untouched', () => {
|
||||
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
|
||||
const noElement: unknown = undefined
|
||||
node.widgets.push({
|
||||
name: '$$comfy_animation_preview',
|
||||
element: noElement as HTMLElement
|
||||
})
|
||||
|
||||
useNodeAnimatedImage().showAnimatedPreview(node)
|
||||
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue'
|
||||
import type { App as VueApp } from 'vue'
|
||||
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const {
|
||||
settings,
|
||||
appState,
|
||||
extensionState,
|
||||
nodeDefState,
|
||||
pricingState,
|
||||
setDirtyMock,
|
||||
addEventListenerMock,
|
||||
registerExtensionMock,
|
||||
getCreditsBadgeMock,
|
||||
updateSubgraphCreditsMock,
|
||||
getNodePricingConfigMock,
|
||||
getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculationMock,
|
||||
useComputedWithWidgetWatchMock
|
||||
} = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
appState: {
|
||||
graph: {
|
||||
nodes: [] as unknown[]
|
||||
}
|
||||
},
|
||||
extensionState: {
|
||||
installed: false,
|
||||
registered: undefined as ComfyExtension | undefined
|
||||
},
|
||||
nodeDefState: {
|
||||
value: null as Record<string, unknown> | null
|
||||
},
|
||||
pricingState: {
|
||||
revision: { value: 0 },
|
||||
config: undefined as
|
||||
| {
|
||||
depends_on?: {
|
||||
widgets?: string[]
|
||||
inputs?: string[]
|
||||
input_groups?: string[]
|
||||
}
|
||||
}
|
||||
| undefined,
|
||||
label: '1 credit'
|
||||
},
|
||||
setDirtyMock: vi.fn(),
|
||||
addEventListenerMock: vi.fn(),
|
||||
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
|
||||
extensionState.registered = extension
|
||||
}),
|
||||
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
|
||||
updateSubgraphCreditsMock: vi.fn(),
|
||||
getNodePricingConfigMock: vi.fn(() => pricingState.config),
|
||||
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
|
||||
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
|
||||
triggerPriceRecalculationMock: vi.fn(),
|
||||
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
setDirty: setDirtyMock,
|
||||
canvas: {
|
||||
addEventListener: addEventListenerMock
|
||||
},
|
||||
graph: appState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
isExtensionInstalled: () => extensionState.installed,
|
||||
registerExtension: registerExtensionMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
fromLGraphNode: () => nodeDefState.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#fff',
|
||||
BADGE_BG_COLOR: '#000'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const pricingRevision = ref(pricingState.revision.value)
|
||||
Object.defineProperty(pricingState.revision, 'value', {
|
||||
get: () => pricingRevision.value,
|
||||
set: (value: number) => {
|
||||
pricingRevision.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useNodePricing: () => ({
|
||||
pricingRevision,
|
||||
getNodePricingConfig: getNodePricingConfigMock,
|
||||
getNodeDisplayPrice: getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNames: getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculation: triggerPriceRecalculationMock
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
getCreditsBadge: getCreditsBadgeMock,
|
||||
updateSubgraphCredits: updateSubgraphCreditsMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useWatchWidget', () => ({
|
||||
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
|
||||
}))
|
||||
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
|
||||
function mountBadge(): VueApp {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useNodeBadge()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
return app
|
||||
}
|
||||
|
||||
function registeredExtension(): ComfyExtension {
|
||||
if (!extensionState.registered)
|
||||
throw new Error('Missing registered extension')
|
||||
return extensionState.registered
|
||||
}
|
||||
|
||||
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
|
||||
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
|
||||
}
|
||||
|
||||
function callNodeCreated(node: LGraphNode) {
|
||||
registeredExtension().nodeCreated?.(node, comfyApp())
|
||||
}
|
||||
|
||||
function inputSlot(name: string) {
|
||||
return new LGraphNode('slot').addInput(name, '*')
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
}
|
||||
|
||||
describe('useNodeBadge', () => {
|
||||
let mountedApp: VueApp | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
defaultSettings()
|
||||
extensionState.installed = false
|
||||
extensionState.registered = undefined
|
||||
appState.graph.nodes = []
|
||||
nodeDefState.value = null
|
||||
pricingState.revision.value = 0
|
||||
pricingState.config = undefined
|
||||
pricingState.label = '1 credit'
|
||||
setDirtyMock.mockClear()
|
||||
addEventListenerMock.mockClear()
|
||||
registerExtensionMock.mockClear()
|
||||
getCreditsBadgeMock.mockClear()
|
||||
updateSubgraphCreditsMock.mockClear()
|
||||
getNodePricingConfigMock.mockClear()
|
||||
getNodeDisplayPriceMock.mockClear()
|
||||
getRelevantWidgetNamesMock.mockClear()
|
||||
triggerPriceRecalculationMock.mockClear()
|
||||
useComputedWithWidgetWatchMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount()
|
||||
mountedApp = undefined
|
||||
})
|
||||
|
||||
it('does not register the badge extension twice', async () => {
|
||||
extensionState.installed = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
expect(registerExtensionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds the configured node identity badge', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: 'Beta',
|
||||
nodeSource: { badgeText: 'Pack' }
|
||||
}
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = toNodeId('7')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(node.badgePosition).toBe(BadgePosition.TopRight)
|
||||
expect(badge().text).toBe('#7 Beta Pack')
|
||||
})
|
||||
|
||||
it('hides built-in badge text when the mode excludes core nodes', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: true,
|
||||
nodeLifeCycleBadgeText: 'Core',
|
||||
nodeSource: { badgeText: 'Built-in' }
|
||||
}
|
||||
const node = new LGraphNode('Core')
|
||||
node.id = toNodeId('11')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#11')
|
||||
})
|
||||
|
||||
it('keeps optional node definition badge text empty', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefState.value = null
|
||||
const node = new LGraphNode('NoDef')
|
||||
node.id = toNodeId('13')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#13')
|
||||
})
|
||||
|
||||
it('marks the canvas dirty when pricing changes while pricing badges are visible', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
pricingState.revision.value++
|
||||
await nextTick()
|
||||
|
||||
expect(setDirtyMock).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not add API pricing badges when the pricing setting is disabled', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(node.badges).toHaveLength(1)
|
||||
expect(getCreditsBadgeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds static API pricing badges without widget watchers', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = undefined
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(node.badges).toHaveLength(2)
|
||||
expect(useComputedWithWidgetWatchMock).not.toHaveBeenCalled()
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
})
|
||||
|
||||
it('adds dynamic widget pricing without connection hooks when no inputs matter', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed']
|
||||
}
|
||||
}
|
||||
const node = new ApiNode('API')
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalled()
|
||||
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
})
|
||||
|
||||
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed'],
|
||||
inputs: ['image'],
|
||||
input_groups: ['lora']
|
||||
}
|
||||
}
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const node = new ApiNode('API')
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
|
||||
widgetNames: ['seed'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
|
||||
const priceBadge = node.badges[1] as () => { text: string }
|
||||
expect(priceBadge().text).toBe('1 credit')
|
||||
pricingState.label = '2 credits'
|
||||
expect(priceBadge().text).toBe('2 credits')
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
|
||||
|
||||
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('refreshes dynamic pricing inputs without an existing connection hook', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
inputs: ['image']
|
||||
}
|
||||
}
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('updates subgraph credit badges from registered extension hooks', async () => {
|
||||
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
|
||||
appState.graph.nodes = nodes
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
const convertedHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'subgraph-converted'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
|
||||
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
|
||||
})
|
||||
|
||||
it('handles empty graph nodes during registered extension hooks', async () => {
|
||||
const noNodes: unknown = undefined
|
||||
appState.graph.nodes = noNodes as LGraphNode[]
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
|
||||
expect(updateSubgraphCreditsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -65,29 +65,4 @@ describe('useNodeCanvasImagePreview', () => {
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when removing from a node without widgets', () => {
|
||||
const node = Object.assign(new LGraphNode('test'), { widgets: undefined })
|
||||
|
||||
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes an existing preview widget and calls its cleanup', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const widget = node.addWidget(
|
||||
'text',
|
||||
'$$canvas-image-preview',
|
||||
'',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
widget.onRemove = vi.fn()
|
||||
|
||||
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
|
||||
|
||||
expect(widget.onRemove).toHaveBeenCalledOnce()
|
||||
expect(node.widgets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
|
||||
@@ -28,25 +28,8 @@ describe('useNodeVideo', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function installMockImage() {
|
||||
const images: HTMLImageElement[] = []
|
||||
class MockImage {
|
||||
onload: ((event: Event) => void) | null = null
|
||||
onerror: ((event: Event) => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
const self: unknown = this
|
||||
images.push(self as HTMLImageElement)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
return images
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
@@ -110,103 +93,4 @@ describe('useNodeVideo', () => {
|
||||
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads image previews and marks the graph dirty', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
const images = installMockImage()
|
||||
const graph = { setDirtyCanvas: vi.fn() }
|
||||
const node = createMockMediaNode({ graph })
|
||||
const callback = vi.fn()
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://image/1.png'])
|
||||
|
||||
const { showPreview } = useNodeImage(node, callback)
|
||||
showPreview({ block: true })
|
||||
images[0].onload?.(new Event('load'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(node.previewMediaType).toBe('image')
|
||||
expect(node.imageIndex).toBeNull()
|
||||
expect(node.imgs).toEqual([images[0]])
|
||||
expect(node.isLoading).toBe(false)
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not start image loads while already loading or without output URLs', () => {
|
||||
vi.clearAllMocks()
|
||||
const images = installMockImage()
|
||||
const node = createMockMediaNode()
|
||||
const { showPreview } = useNodeImage(node)
|
||||
|
||||
node.isLoading = true
|
||||
showPreview()
|
||||
node.isLoading = false
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(undefined)
|
||||
showPreview()
|
||||
|
||||
expect(images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retries image loading once when the first load fails', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
const images = installMockImage()
|
||||
const graph = { setDirtyCanvas: vi.fn() }
|
||||
const node = createMockMediaNode({ graph })
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue([
|
||||
'http://image/missing.png'
|
||||
])
|
||||
|
||||
const staleImgs = [document.createElement('img')]
|
||||
node.imgs = staleImgs
|
||||
|
||||
const { showPreview } = useNodeImage(node)
|
||||
showPreview()
|
||||
images[0].onerror?.(new Event('error'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
images[1].onerror?.(new Event('error'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(images).toHaveLength(2)
|
||||
// Failed loads never resolve to elements, so existing previews are untouched
|
||||
expect(node.imgs).toBe(staleImgs)
|
||||
expect(graph.setDirtyCanvas).not.toHaveBeenCalled()
|
||||
expect(node.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('reuses an existing video-preview widget when loading a video', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
|
||||
const node = createMockMediaNode({
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
})
|
||||
node.widgets.push({
|
||||
name: 'video-preview',
|
||||
element: document.createElement('div')
|
||||
})
|
||||
const callback = vi.fn()
|
||||
|
||||
const createdVideos: HTMLVideoElement[] = []
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tag: string, opts?: ElementCreationOptions) => {
|
||||
const el = realCreateElement(tag, opts)
|
||||
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
|
||||
return el
|
||||
}
|
||||
)
|
||||
|
||||
const { showPreview } = useNodeVideo(node, callback)
|
||||
showPreview()
|
||||
const video = createdVideos[0]
|
||||
video.onloadeddata?.(new Event('loadeddata'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(node.addDOMWidget).not.toHaveBeenCalled()
|
||||
expect(node.videoContainer?.firstChild).toBe(video)
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
@@ -126,35 +123,6 @@ function createMockNode(
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveDisplayPrice(
|
||||
node: LGraphNode,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): Promise<string> {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
getNodeDisplayPrice(node, widgetOverrides)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return getNodeDisplayPrice(node, widgetOverrides)
|
||||
}
|
||||
|
||||
function createStoredNodeDef(
|
||||
name: string,
|
||||
price_badge?: PriceBadge
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
price_badge
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -221,32 +189,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.5))
|
||||
})
|
||||
|
||||
it('should parse numeric strings and reject blank or invalid numbers', async () => {
|
||||
const expression =
|
||||
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
|
||||
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
|
||||
|
||||
const parsedNode = createMockNodeWithPriceBadge(
|
||||
'TestNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: ' 5 ' }]
|
||||
)
|
||||
const blankNode = createMockNodeWithPriceBadge(
|
||||
'TestBlankNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: ' ' }]
|
||||
)
|
||||
const invalidNode = createMockNodeWithPriceBadge(
|
||||
'TestInvalidNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: 'five' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
|
||||
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
|
||||
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
|
||||
})
|
||||
|
||||
it('should handle COMBO widget with numeric value', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -280,19 +222,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.1))
|
||||
})
|
||||
|
||||
it('should preserve boolean combo values', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestComboBooleanNode',
|
||||
priceBadge(
|
||||
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
|
||||
[{ name: 'enabled', type: 'COMBO' }]
|
||||
),
|
||||
[{ name: 'enabled', value: false }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
|
||||
})
|
||||
|
||||
it('should handle BOOLEAN widget', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -309,64 +238,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.1))
|
||||
})
|
||||
|
||||
it('should parse BOOLEAN widget string values', async () => {
|
||||
const badge = priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
)
|
||||
const enabledNode = createMockNodeWithPriceBadge(
|
||||
'TestBooleanStringTrueNode',
|
||||
badge,
|
||||
[{ name: 'premium', value: ' TRUE ' }]
|
||||
)
|
||||
const disabledNode = createMockNodeWithPriceBadge(
|
||||
'TestBooleanStringFalseNode',
|
||||
badge,
|
||||
[{ name: 'premium', value: 'false' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
|
||||
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject invalid BOOLEAN strings', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInvalidBooleanStringNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
),
|
||||
[{ name: 'premium', value: 'sometimes' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject non-boolean values for BOOLEAN widgets', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInvalidBooleanNumberNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
),
|
||||
[{ name: 'premium', value: 1 }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject object values for numeric widgets', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestObjectNumericNode',
|
||||
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
|
||||
{ name: 'count', type: 'INT' }
|
||||
]),
|
||||
[{ name: 'count', value: { count: 5 } }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should handle STRING widget (lowercased)', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -597,42 +468,6 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dependency context', () => {
|
||||
it('should prefer widget overrides over node widget values', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestWidgetOverrideNode',
|
||||
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
|
||||
{ name: 'count', type: 'INT' }
|
||||
]),
|
||||
[{ name: 'count', value: 2 }]
|
||||
)
|
||||
|
||||
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
|
||||
|
||||
expect(price).toBe(creditsLabel(0.07))
|
||||
})
|
||||
|
||||
it('should treat missing input group arrays as zero connected inputs', async () => {
|
||||
const node = Object.assign(createMockLGraphNode(), {
|
||||
widgets: [],
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'TestMissingInputGroupArrayNode',
|
||||
api_node: true,
|
||||
price_badge: priceBadge(
|
||||
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
|
||||
[],
|
||||
[],
|
||||
['images']
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty string for non-API nodes', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
@@ -706,44 +541,6 @@ describe('useNodePricing', () => {
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should default missing price badge engine and dependency arrays', async () => {
|
||||
const bareBadge = {
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge
|
||||
const node = createMockNodeWithPriceBadge('TestBareBadgeNode', bareBadge)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
expect(getNodePricingConfig(node)).toMatchObject({
|
||||
engine: 'jsonata',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore non-jsonata pricing engines', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const literalEngineBadge: unknown = {
|
||||
engine: 'literal',
|
||||
expr: '{"type":"usd","usd":0.05}',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
}
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestUnsupportedEngineNode',
|
||||
literalEngineBadge as PriceBadge
|
||||
)
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodePricingConfig', () => {
|
||||
@@ -798,107 +595,6 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node type pricing dependencies', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns empty dependency metadata for node types without pricing', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
|
||||
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
|
||||
expect(getInputNames('UnpricedNode')).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes dynamic pricing dependencies while preserving order', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef(
|
||||
'DynamicPricingNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd":0.05}',
|
||||
[
|
||||
{ name: 'seed', type: 'INT' },
|
||||
{ name: 'quality', type: 'COMBO' }
|
||||
],
|
||||
['image', 'seed'],
|
||||
['clips', 'image']
|
||||
)
|
||||
)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
|
||||
'seed',
|
||||
'quality',
|
||||
'image',
|
||||
'clips'
|
||||
])
|
||||
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
|
||||
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
|
||||
'clips',
|
||||
'image'
|
||||
])
|
||||
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
|
||||
})
|
||||
|
||||
it('handles fixed pricing metadata without dependencies', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef(
|
||||
'FixedPricingNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
|
||||
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
|
||||
expect(getInputNames('FixedPricingNode')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles price badges with omitted dependency metadata', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef('BareDependencyNode', {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('BareDependencyNode')).toEqual([])
|
||||
expect(hasDynamicPricing('BareDependencyNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('BareDependencyNode')).toEqual([])
|
||||
expect(getInputNames('BareDependencyNode')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactive revision', () => {
|
||||
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
|
||||
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
|
||||
@@ -959,20 +655,6 @@ describe('useNodePricing', () => {
|
||||
expect(second).toBe(first)
|
||||
expect(pricingRevision.value).toBe(tickAfterFirst)
|
||||
})
|
||||
|
||||
it('does not schedule duplicate work for the same in-flight signature', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInFlightSignatureNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeRevisionRef', () => {
|
||||
@@ -1061,16 +743,6 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should reuse the cached empty label after runtime failures', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestCachedRuntimeErrorNode',
|
||||
priceBadge('$lookup(undefined, "key")')
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for invalid PricingResult type', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -1296,21 +968,8 @@ describe('formatPricingResult', () => {
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should parse string usd values with default approximate formatting', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'usd', usd: '0.05' },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should return empty for null usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: null })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for blank string usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: ' ' })
|
||||
const result = formatPricingResult({ type: 'usd', usd: null as never })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1340,14 +999,6 @@ describe('formatPricingResult', () => {
|
||||
)
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should parse string range values with default approximate formatting', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6-21.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: list_usd', () => {
|
||||
@@ -1366,22 +1017,6 @@ describe('formatPricingResult', () => {
|
||||
)
|
||||
expect(result).toBe('10.6/21.1')
|
||||
})
|
||||
|
||||
it('should return valueOnly format with approximate prefix', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'list_usd', usd: [0.05, 0.1] },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6/21.1')
|
||||
})
|
||||
|
||||
it('should return empty when list value is not an array', () => {
|
||||
const result = formatPricingResult({
|
||||
type: 'list_usd',
|
||||
usd: 'not-a-list'
|
||||
})
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: text', () => {
|
||||
@@ -1389,11 +1024,6 @@ describe('formatPricingResult', () => {
|
||||
const result = formatPricingResult({ type: 'text', text: 'Free' })
|
||||
expect(result).toBe('Free')
|
||||
})
|
||||
|
||||
it('should return empty when text is missing', () => {
|
||||
const result = formatPricingResult({ type: 'text' })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy format', () => {
|
||||
@@ -1538,20 +1168,6 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should evaluate price badges with omitted dependency metadata', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'BareNodeDefPriceBadge',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge
|
||||
})
|
||||
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should use default value from input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'DefaultValueNode',
|
||||
@@ -1574,29 +1190,6 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
|
||||
})
|
||||
|
||||
it('should use default value from optional input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'OptionalDefaultValueNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.count * 0.01}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'count', type: 'INT' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
count: ['INT', { default: 4 }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('8.4')
|
||||
})
|
||||
|
||||
it('should use first option for COMBO without default', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'ComboNode',
|
||||
@@ -1672,30 +1265,6 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should handle combo option arrays with primitive values', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'PrimitiveOptionsNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'mode', type: 'COMBO' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
mode: ['COMBO', { options: ['fast', 'slow'] }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should assume inputs disconnected in preview', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'InputConnectedNode',
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const mockTextPreviewWidget = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget',
|
||||
() => ({
|
||||
useTextPreviewWidget: () => mockTextPreviewWidget
|
||||
})
|
||||
)
|
||||
|
||||
import { useNodeProgressText } from './useNodeProgressText'
|
||||
|
||||
function node(widgets?: IBaseWidget[]): LGraphNode {
|
||||
return createMockLGraphNode({ widgets, setDirtyCanvas: vi.fn() })
|
||||
}
|
||||
|
||||
describe('useNodeProgressText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTextPreviewWidget.mockImplementation(
|
||||
(_node: LGraphNode, spec: { name: string; type: string }) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
value: ''
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('updates an existing text preview widget', () => {
|
||||
const existing = { name: '$$node-text-preview', value: '' } as IBaseWidget
|
||||
const graphNode = node([existing])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'running')
|
||||
|
||||
expect(existing.value).toBe('running')
|
||||
expect(mockTextPreviewWidget).not.toHaveBeenCalled()
|
||||
expect(graphNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('creates a text preview widget when one is missing', () => {
|
||||
const createdWidget = fromPartial<IBaseWidget>({
|
||||
name: '$$node-text-preview',
|
||||
type: 'progressText',
|
||||
value: ''
|
||||
})
|
||||
mockTextPreviewWidget.mockReturnValueOnce(createdWidget)
|
||||
const graphNode = node([])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'queued')
|
||||
|
||||
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
|
||||
name: '$$node-text-preview',
|
||||
type: 'progressText'
|
||||
})
|
||||
expect(createdWidget.value).toBe('queued')
|
||||
})
|
||||
|
||||
it('removes an existing preview widget and calls its cleanup', () => {
|
||||
const onRemove = vi.fn()
|
||||
const keep = { name: 'other' } as IBaseWidget
|
||||
const preview = fromPartial<IBaseWidget & { onRemove?: () => void }>({
|
||||
name: '$$node-text-preview',
|
||||
onRemove
|
||||
})
|
||||
const graphNode = node([keep, preview])
|
||||
const { removeTextPreview } = useNodeProgressText()
|
||||
|
||||
removeTextPreview(graphNode)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
expect(graphNode.widgets).toEqual([keep])
|
||||
})
|
||||
|
||||
it('does nothing when there are no widgets or no preview widget', () => {
|
||||
const { removeTextPreview } = useNodeProgressText()
|
||||
const withoutWidgets = node()
|
||||
const withoutPreview = node([{ name: 'other' } as IBaseWidget])
|
||||
|
||||
removeTextPreview(withoutWidgets)
|
||||
removeTextPreview(withoutPreview)
|
||||
|
||||
expect(withoutWidgets.widgets).toBeUndefined()
|
||||
expect(withoutPreview.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -6,34 +6,26 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
const mockPalette = vi.hoisted(() => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => mockPalette
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: { litegraph_base: {} }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const { updateSubgraphCredits, getCreditsBadge, isCreditsBadge } =
|
||||
usePriceBadge()
|
||||
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
|
||||
|
||||
const mockNode = new LGraphNode('mock node')
|
||||
mockNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
@@ -44,34 +36,6 @@ function getBadgeText(node: LGraphNode): string {
|
||||
}
|
||||
|
||||
describe('subgraph pricing', () => {
|
||||
beforeEach(() => {
|
||||
mockPalette.completedActivePalette.light_theme = false
|
||||
})
|
||||
|
||||
it('identifies credit badges and ignores unrelated badges', () => {
|
||||
expect(isCreditsBadge(getCreditsBadge('$0.05/Run'))).toBe(true)
|
||||
expect(isCreditsBadge(() => getCreditsBadge('$0.05/Run'))).toBe(true)
|
||||
expect(isCreditsBadge({ text: 'other' })).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the adjusted credits background in light themes', () => {
|
||||
mockPalette.completedActivePalette.light_theme = true
|
||||
|
||||
expect(getCreditsBadge('$0.05/Run').bgColor).toBe(
|
||||
adjustColor('#8D6932', { lightness: 0.5 })
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing for non-subgraph nodes', () => {
|
||||
const node = new LGraphNode('plain node')
|
||||
const badge = getCreditsBadge('$0.05/Run')
|
||||
node.badges = [badge]
|
||||
|
||||
updateSubgraphCredits(node)
|
||||
|
||||
expect(node.badges).toEqual([badge])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should not display badge for subgraphs without API nodes',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -151,9 +151,7 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
})
|
||||
|
||||
it('should handle nodes without widgets gracefully', () => {
|
||||
const mockNode = Object.assign(createMockLGraphNode(), {
|
||||
widgets: undefined
|
||||
}) as LGraphNode
|
||||
const mockNode = createMockNode([])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
@@ -162,85 +160,6 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
expect(computedValue.value).toBe('no widgets')
|
||||
})
|
||||
|
||||
it('observes named input connection changes when requested', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 0, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(2)
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('observes connection changes for watched inputs at non-zero slots', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'other' }, { name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 1, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(2)
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ignores unobserved input connection changes', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image']
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 1, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should chain with existing widget callbacks', async () => {
|
||||
const existingCallback = vi.fn()
|
||||
const mockNode = createMockNode([
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
@@ -13,12 +12,8 @@ afterEach(() => {
|
||||
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef<HTMLDivElement | null>(
|
||||
document.createElement('div')
|
||||
)
|
||||
const picker = shallowRef<HTMLInputElement | null>(
|
||||
document.createElement('input')
|
||||
)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
@@ -27,13 +22,7 @@ function setup(initial: string[]) {
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => fromPartial<MouseEvent>({ stopPropagation: vi.fn() })
|
||||
|
||||
function makeInputEvent(value: string): Event {
|
||||
return fromPartial<Event>({
|
||||
target: fromPartial<HTMLInputElement>({ value })
|
||||
})
|
||||
}
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
@@ -62,29 +51,16 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('tracks the picker index even when the input is unavailable', () => {
|
||||
const { modelValue, picker, openPicker, onPickerInput } = setup([
|
||||
'#000000',
|
||||
'#111111'
|
||||
])
|
||||
picker.value = null
|
||||
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput(makeInputEvent('#222222'))
|
||||
|
||||
expect(modelValue.value).toEqual(['#000000', '#222222'])
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput(makeInputEvent('#123456'))
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput(makeInputEvent('#123456'))
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
})
|
||||
|
||||
@@ -124,82 +100,6 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores pointer movement before a drag starts', () => {
|
||||
const { modelValue } = setup(['#a', '#b'])
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('waits until movement passes the drag threshold', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 12, clientY: 11, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores active drags when the row container is gone', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
container.value = null
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores invalid target rows during drag', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const current = document.createElement('div')
|
||||
current.setAttribute('data-index', '0')
|
||||
const invalid = document.createElement('div')
|
||||
invalid.setAttribute('data-index', '-1')
|
||||
container.value!.append(current, invalid)
|
||||
invalid.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('cancels drags on pointerup and pointercancel', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
swatch.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(new PointerEvent('pointerup'))
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores non-left-button pointer downs', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { distribution, downloads } = vi.hoisted(() => ({
|
||||
distribution: { isDesktop: false },
|
||||
downloads: { values: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/ModelLibrarySidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({
|
||||
useElectronDownloadStore: () => ({
|
||||
inProgressDownloads: downloads.values
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useModelLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isDesktop = false
|
||||
downloads.values = []
|
||||
})
|
||||
|
||||
it('hides the badge outside desktop builds', async () => {
|
||||
distribution.isDesktop = false
|
||||
downloads.values = [{ id: 'download-1' }]
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows active desktop download count', async () => {
|
||||
distribution.isDesktop = true
|
||||
downloads.values = [{ id: 'a' }, { id: 'b' }]
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('2')
|
||||
})
|
||||
|
||||
it('hides the badge when desktop has no active downloads', async () => {
|
||||
distribution.isDesktop = true
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { settings } = vi.hoisted(() => ({
|
||||
settings: { newDesign: false }
|
||||
}))
|
||||
|
||||
const legacyComponent = { name: 'NodeLibrarySidebarTab' }
|
||||
const newDesignComponent = { name: 'NodeLibrarySidebarTabV2' }
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTab.vue', () => ({
|
||||
default: legacyComponent
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue', () => ({
|
||||
default: newDesignComponent
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.NodeLibrary.NewDesign' && settings.newDesign
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
settings.newDesign = false
|
||||
})
|
||||
|
||||
it('uses the legacy node library component by default', async () => {
|
||||
const { useNodeLibrarySidebarTab } =
|
||||
await import('./useNodeLibrarySidebarTab')
|
||||
|
||||
const tab = useNodeLibrarySidebarTab()
|
||||
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
|
||||
expect(tab.component).toBe(legacyComponent)
|
||||
})
|
||||
|
||||
it('uses the new node library component when the setting is enabled', async () => {
|
||||
settings.newDesign = true
|
||||
const { useNodeLibrarySidebarTab } =
|
||||
await import('./useNodeLibrarySidebarTab')
|
||||
|
||||
const tab = useNodeLibrarySidebarTab()
|
||||
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
|
||||
expect(tab.component).toBe(newDesignComponent)
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import { useTreeFolderOperations } from './useTreeFolderOperations'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function withI18n<T>(fn: () => T): T {
|
||||
let result!: T
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = fn()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.use(i18n)
|
||||
app.mount(document.createElement('div'))
|
||||
return result
|
||||
}
|
||||
|
||||
function makeFolder(
|
||||
overrides: Partial<RenderedTreeExplorerNode> = {}
|
||||
): RenderedTreeExplorerNode {
|
||||
return {
|
||||
key: 'root',
|
||||
label: 'Root',
|
||||
leaf: false,
|
||||
children: [],
|
||||
icon: 'pi pi-folder',
|
||||
type: 'folder',
|
||||
totalLeaves: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTreeFolderOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123)
|
||||
})
|
||||
|
||||
it('creates a temporary editable folder under the selected target', () => {
|
||||
const expandNode = vi.fn()
|
||||
const target = makeFolder({ key: 'models', handleAddFolder: vi.fn() })
|
||||
const operations = withI18n(() => useTreeFolderOperations(expandNode))
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
|
||||
expect(expandNode).toHaveBeenCalledWith(target)
|
||||
expect(operations.newFolderNode.value).toMatchObject({
|
||||
key: 'models/new_folder_123',
|
||||
label: '',
|
||||
leaf: false,
|
||||
icon: 'pi pi-folder',
|
||||
type: 'folder',
|
||||
isEditingLabel: true
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the confirmed name to the target and clears temporary state', async () => {
|
||||
const handleAddFolder = vi.fn()
|
||||
const target = makeFolder({ handleAddFolder })
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
await operations.handleFolderCreation('New Folder')
|
||||
|
||||
expect(handleAddFolder).toHaveBeenCalledWith('New Folder')
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears temporary state even when folder creation fails', async () => {
|
||||
const handleAddFolder = vi.fn().mockRejectedValue(new Error('failed'))
|
||||
const target = makeFolder({ handleAddFolder })
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
|
||||
await expect(operations.handleFolderCreation('New Folder')).rejects.toThrow(
|
||||
'failed'
|
||||
)
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores folder creation when no target is pending', async () => {
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
await operations.handleFolderCreation('Unused')
|
||||
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a hidden menu item when the target cannot add folders', () => {
|
||||
const operations = withI18n(() => useTreeFolderOperations(vi.fn()))
|
||||
|
||||
expect(operations.getAddFolderMenuItem(null)).toMatchObject({
|
||||
label: 'g.newFolder',
|
||||
visible: false,
|
||||
isAsync: false
|
||||
})
|
||||
expect(
|
||||
operations.getAddFolderMenuItem(makeFolder({ leaf: true }))
|
||||
).toMatchObject({ visible: false })
|
||||
expect(
|
||||
operations.getAddFolderMenuItem(makeFolder({ leaf: false }))
|
||||
).toMatchObject({ visible: false })
|
||||
})
|
||||
|
||||
it('does nothing when the menu command fires without a target', () => {
|
||||
const expandNode = vi.fn()
|
||||
const operations = withI18n(() => useTreeFolderOperations(expandNode))
|
||||
const item = operations.getAddFolderMenuItem(null)
|
||||
|
||||
expect(() =>
|
||||
item.command?.({ originalEvent: new Event('click'), item })
|
||||
).not.toThrow()
|
||||
|
||||
expect(expandNode).not.toHaveBeenCalled()
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('runs the add folder command from a visible menu item', () => {
|
||||
const expandNode = vi.fn()
|
||||
const target = makeFolder({ handleAddFolder: vi.fn() })
|
||||
const operations = withI18n(() => useTreeFolderOperations(expandNode))
|
||||
const item = operations.getAddFolderMenuItem(target)
|
||||
|
||||
expect(item.visible).toBe(true)
|
||||
item.command?.({ originalEvent: new Event('click'), item })
|
||||
|
||||
expect(expandNode).toHaveBeenCalledWith(target)
|
||||
expect(operations.newFolderNode.value?.key).toBe('root/new_folder_123')
|
||||
})
|
||||
})
|
||||