Compare commits

...

8 Commits

Author SHA1 Message Date
huang47
de05790f5b test: drop mock-refactor files, split into follow-up PR
Reverts the 8 test files whose new coverage required rewriting
existing mock scaffolding, keeping this PR purely additive (no
deleted lines except formatting). Rework lands separately in #13411
so it can be reviewed on its own.
2026-07-02 14:41:47 -07:00
huang47
31ebd0a07f Merge remote-tracking branch 'origin/main' into codex/coverage-composables-logic
# Conflicts:
#	src/composables/useFeatureFlags.test.ts
2026-07-02 14:36:39 -07:00
Mobeen Abdullah
4cc0402325 revert(website): remove Creative Campus customer stories (#13370) (#13407)
## Summary

Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.

This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.

## Changes

- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.

## Review Focus

- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.

## Verification

- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.

## Next steps

- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
2026-07-03 01:49:47 +05:00
huang47
470c302879 test: cover critical composable logic 2026-07-02 13:29:36 -07:00
Wei Hai
a2adfe5124 fix(ci): drop unsupported 'range' genhtml ignore-errors category (#13396)
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner

## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
2026-07-02 20:08:47 +00:00
Mobeen Abdullah
49a90d4e2e feat(website): add five Creative Campus customer stories (#13370)
## Summary

Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.

## Changes

- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.

## Review Focus

- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.

## Verification

- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.

## Screenshots (if applicable)

Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
2026-07-03 00:34:20 +05:00
Hunter
d6c582c399 feat(billing): gate consolidated billing behind consolidated_billing_enabled flag (#13359)
## Summary

Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.

## Changes

- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
  - team workspaces disabled → legacy
  - personal workspace + consolidated billing off/missing → legacy
  - personal workspace + consolidated billing on → workspace
  - team workspace → workspace
  - workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.

## Testing

- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.

## Related

Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-07-02 18:34:39 +00:00
imick-io
a6db1ab3d6 fix(website): restore node-link.svg intrinsic sizing (#13384)
## Summary

Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.

## Changes

- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.

## Review Focus

- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 13:07:00 +00:00
74 changed files with 11090 additions and 160 deletions

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--ignore-errors source,unmapped \
--synthesize-missing
- name: Upload HTML report artifact

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg 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 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>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -28,7 +28,12 @@ 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'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// 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
// 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.

View File

@@ -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 { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
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)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
// On the consolidated (workspace) billing flow, show the workspace settings
// panel; otherwise show the legacy subscription/credits panel.
const settingsPanel = shouldUseWorkspaceBilling.value
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'

View File

@@ -2,12 +2,11 @@ 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, onMounted, ref } from 'vue'
import { defineComponent, nextTick, 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'
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
const mockTelemetry = vi.hoisted(() => ({
checkForCompletedTopup: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
useTelemetry: () => mockTelemetry
}))
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
const mockBillingRouting = vi.hoisted(() => ({
shouldUseWorkspaceBilling: false
}))
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()
@@ -68,7 +77,10 @@ const i18n = createI18n({
additionalInfo: 'Additional Info',
added: 'Added',
accountInitialized: 'Account initialized',
model: 'Model'
model: 'Model',
loadEventsError: 'Failed to load activity. Please try again.',
loadEventsUnknownError:
'Something went wrong while loading activity. Please refresh and try again.'
}
}
}
@@ -95,6 +107,11 @@ 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> = {}
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockFlags.teamWorkspacesEnabled = false
mockBillingRouting.shouldUseWorkspaceBilling = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
})
})
it('shows error message when service throws', async () => {
it('shows a localized fallback instead of a raw Error message', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
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()
})
})
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
mockBillingRouting.shouldUseWorkspaceBilling = true
await renderLoaded()
@@ -352,6 +387,90 @@ 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', () => {

View File

@@ -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 } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
@@ -109,14 +109,15 @@ 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 { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const pagination = ref({
page: 1,
@@ -139,7 +140,12 @@ 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
@@ -148,10 +154,17 @@ const loadEvents = async () => {
page: pagination.value.page,
limit: pagination.value.limit
}
const response = useBillingApi.value
const response = shouldUseWorkspaceBilling.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
@@ -165,24 +178,25 @@ const loadEvents = async () => {
pagination.value.limit = response.limit
}
if (response.total) {
if (response.total != null) {
pagination.value.total = response.total
}
if (response.totalPages) {
if (response.totalPages != null) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
const legacyError = shouldUseWorkspaceBilling.value
? null
: customerEventService.error.value
error.value = legacyError || t('credits.loadEventsError')
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
if (loadToken !== latestLoadToken) return
error.value = t('credits.loadEventsUnknownError')
console.error('Error loading events:', err)
} finally {
loading.value = false
if (loadToken === latestLoadToken) loading.value = false
}
}
@@ -198,6 +212,12 @@ const refresh = async () => {
await loadEvents()
}
watch(shouldUseWorkspaceBilling, () => {
refresh().catch((error) => {
console.error('Error loading events:', error)
})
})
defineExpose({
refresh
})

View File

@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -26,6 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -57,11 +59,23 @@ 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
}
}
})
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
it('keeps personal on legacy when consolidated billing is disabled', () => {
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 when team workspaces are enabled', () => {
it('selects workspace type for team regardless of consolidated billing', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -272,6 +298,7 @@ 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(() => {
@@ -280,9 +307,27 @@ 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 when team workspaces are enabled', async () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -294,6 +339,20 @@ 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', () => {

View File

@@ -1,7 +1,6 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
@@ -18,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'
@@ -35,8 +34,9 @@ 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 both
* personal (single-seat workspace) and team workspaces
* - 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
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const { type } = useBillingRouting()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
@@ -96,16 +96,6 @@ 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()
)
@@ -170,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// 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
// 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).
watch(
subscription,
(sub) => {
@@ -186,24 +179,27 @@ 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 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.
// 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.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
resetBillingState()
return
}
resetBillingState()
if (!newWorkspaceId) return
isInitialized.value = false
try {
await initialize()
} catch (err) {
@@ -216,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
async function initialize(): Promise<void> {
if (isInitialized.value) return
const adapter = activeContext.value
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
await adapter.initialize()
if (activeContext.value !== adapter) return
isInitialized.value = true
} catch (err) {
if (activeContext.value !== adapter) return
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
if (activeContext.value === adapter) isLoading.value = false
}
}

View File

@@ -0,0 +1,99 @@
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')
})
})

View File

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

View File

@@ -5,6 +5,7 @@ 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'
@@ -35,11 +36,26 @@ const ctx = {
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
function makeCanvas(
options: {
context?: CanvasRenderingContext2D | null
clientWidth?: number
clientHeight?: number
} = {}
): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
Object.defineProperty(el, 'clientWidth', {
value: options.clientWidth ?? 100,
configurable: true
})
Object.defineProperty(el, 'clientHeight', {
value: options.clientHeight ?? 100,
configurable: true
})
el.getContext = (() =>
options.context === undefined
? ctx
: options.context) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
@@ -96,14 +112,14 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] = []) {
function setup(initial: BoundingBox[] | undefined = []) {
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)
const modelValue = ref(initial as BoundingBox[])
const api = useBoundingBoxes(toNodeId('1'), {
canvasEl,
canvasContainer,
@@ -159,9 +175,43 @@ 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))
@@ -187,6 +237,102 @@ 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 fallback palette color', async () => {
const c = setup([
box({
x: 10,
y: 10,
width: 30,
height: 30,
metadata: {
type: 'text',
text: 'hello',
desc: 'alpha beta\n\ncharlie',
palette: []
}
})
])
c.focused.value = true
c.syncState()
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
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', () => {
@@ -214,6 +360,60 @@ 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({
key: 'Backspace',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('ignores unrelated keys and key events while drawing', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Enter',
preventDefault: () => {
throw new Error('should not prevent')
},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
c.onPointerDown(pe(80, 80))
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {
throw new Error('should not prevent while drawing')
},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
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({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.activeRegion.value).not.toBeNull()
})
})
describe('useBoundingBoxes inline editor', () => {
@@ -237,6 +437,86 @@ describe('useBoundingBoxes inline editor', () => {
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 unknown 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 unknown 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 unknown 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 unknown 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 unknown 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 unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
@@ -247,4 +527,74 @@ 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)
})
})

View File

@@ -0,0 +1,118 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
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', fromAny<LGraphNode, unknown>(node)]])
)
expect(getNodeByExecutionId).not.toHaveBeenCalled()
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
})

View File

@@ -0,0 +1,116 @@
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)
})
})

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => {
const canvas = {
canvas: {},
ds: {
offset: [10, 20],
scale: 2
}
} as unknown 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)
})
})

View File

@@ -0,0 +1,82 @@
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()
})
})

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import {
@@ -360,5 +360,203 @@ 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()
})
})
})

View File

@@ -20,6 +20,7 @@ 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'
@@ -130,6 +131,39 @@ 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'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 12, true, null, fromAny(null))
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears errors for pure input slots without widget property', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
@@ -252,6 +286,36 @@ describe('Widget change error clearing via onWidgetChanged', () => {
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)
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('clears missing media when an upload emits onWidgetChanged', () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
@@ -391,6 +455,124 @@ 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)
@@ -734,6 +916,84 @@ 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', () => {
@@ -893,6 +1153,54 @@ 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', () => {
@@ -1004,6 +1312,167 @@ 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')
node.type = fromAny<LGraphNode['type'], unknown>(undefined)
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()

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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()
}
}
}))
const mockGroups = vi.hoisted(() => ({
instances: [] as Array<{
resizeTo: ReturnType<typeof 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
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LGraphGroup: class MockLGraphGroup {
resizeTo = vi.fn()
constructor() {
mockGroups.instances.push(this)
}
}
}))
describe('useFrameNodes', () => {
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()
}
mockGroups.instances = []
})
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(mockGroups.instances).toHaveLength(0)
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 = mockGroups.instances[0]
expect(group.resizeTo).toHaveBeenCalledWith(
mockApp.canvas.selectedItems,
24
)
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
})
})

View File

@@ -1,9 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import {
extractVueNodeData,
getControlWidget,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
@@ -14,8 +19,10 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
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(() => {
@@ -263,6 +270,26 @@ 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', () => {
@@ -756,3 +783,535 @@ 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()
useGraphNodeManager(graph)
expect(() =>
graph.trigger('node:property:changed', {
nodeId: 'missing',
property: 'title',
newValue: 'ignored'
})
).not.toThrow()
})
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)
input.link = fromAny(123)
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(
fromAny<IBaseWidget, unknown>({
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)
node.addCustomWidget(
fromAny<IBaseWidget, 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'
})
)
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 badWidget = fromAny<IBaseWidget, unknown>({
name: 'broken',
type: 'custom',
value: 'x',
get options() {
throw new Error('bad options')
}
})
node.widgets = [badWidget]
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 badWidget = fromAny<IBaseWidget, unknown>({
value: 'x',
get options() {
throw new Error('bad options')
}
})
node.widgets = [badWidget]
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)
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')
})
})
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 widget = {
linkedWidgets: [linkedControl]
} as unknown as IBaseWidget
const control = getControlWidget(widget)
expect(control?.value).toBe('fixed')
control?.update('unexpected')
expect(linkedControl.value).toBe('randomize')
})
})

View File

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

View File

@@ -1,6 +1,7 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
@@ -19,6 +20,11 @@ vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
@@ -27,6 +33,15 @@ 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 {
@@ -45,8 +60,13 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
@@ -75,6 +95,12 @@ describe('useImageMenuOptions', () => {
expect(getImageMenuOptions(node)).toEqual([])
})
it('returns empty array when node image capabilities are absent', () => {
const { getImageMenuOptions } = useImageMenuOptions()
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
})
it('returns only Paste Image when node has no images but supports paste', () => {
const node = createMockLGraphNode({
imgs: [],
@@ -182,4 +208,225 @@ 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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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 } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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 } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(null)
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
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(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to copy image to clipboard:',
expect.any(Error)
)
})
})
})

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
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' })
)
})
})

View File

@@ -8,7 +8,12 @@ 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, isLGraphNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -17,7 +22,9 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -96,6 +103,14 @@ 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')
)
@@ -135,6 +150,21 @@ 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', () => {
@@ -215,6 +245,13 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -0,0 +1,342 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
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 fromAny<LGraphNode, unknown>({
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()
})
})

View File

@@ -23,8 +23,8 @@ const mockStore = reactive({
gpuTexturesNeedRecreation: false,
gpuTextureWidth: 0,
gpuTextureHeight: 0,
pendingGPUMaskData: null as null,
pendingGPURgbData: null as null,
pendingGPUMaskData: null as Uint8Array | null,
pendingGPURgbData: null as Uint8Array | null,
brushSettings: {
size: 20,
hardness: 0.9,
@@ -42,6 +42,9 @@ 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'
@@ -52,8 +55,120 @@ function setup() {
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() {
return {
createView: vi.fn(() => ({})),
destroy: vi.fn()
} as unknown as GPUTexture
}
function createMockBuffer(byteLength = 16) {
return {
destroy: vi.fn(),
mapAsync: vi.fn().mockResolvedValue(undefined),
getMappedRange: vi.fn(() => new Uint8Array(byteLength).buffer),
unmap: vi.fn()
} as unknown as GPUBuffer
}
function createMockDevice() {
return {
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(() => ({}))
}))
} as unknown 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() {
return renderer as unknown as GPUBrushRenderer
}
)
}
function createCanvasContext(width: number, height: number) {
return {
globalCompositeOperation: 'source-over',
getImageData: vi.fn(
() =>
new ImageData(new Uint8ClampedArray(width * height * 4), width, height)
),
putImageData: vi.fn()
} as unknown 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
@@ -62,11 +177,28 @@ 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()
})
describe('initial reactive state', () => {
@@ -131,6 +263,34 @@ 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', () => {
@@ -174,6 +334,18 @@ 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', () => {
@@ -182,6 +354,47 @@ 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', () => {
@@ -189,4 +402,70 @@ 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)
})
})

View File

@@ -0,0 +1,408 @@
import { fromAny } from '@total-typescript/shoehorn'
import { 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 {
return fromAny({
id: 42,
imgs: [{ src: 'http://images.test/render.png?filename=render.png' }],
imageIndex: 0,
...overrides
})
}
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
})
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({
ok: true,
json: vi.fn().mockResolvedValue({
painted_masked: 'painted-masked.png',
painted: 'painted.png',
paint: 'paint.png'
})
} as unknown as Response)
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({
ok: true,
json: vi.fn().mockResolvedValue({
painted: 'painted-only.png'
})
} as unknown as Response)
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 () => {
await expect(
useMaskEditorLoader().loadFromNode(null as unknown 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:')
})
})

View File

@@ -201,4 +201,108 @@ 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 = [fromAny({ name: 'other', value: 'unchanged' })]
mockNode.widgets_values = ['unchanged']
mockNode.properties = fromAny(undefined)
mockNode.graph = fromAny(undefined)
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'])
})
})

View File

@@ -181,6 +181,50 @@ 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() as unknown as HTMLElement
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() as unknown as HTMLElement
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
await pz.initializeCanvasPanZoom(
createMockImage(400, 300),
createMockElement()
)
expect(mockStore.setZoomRatio).toHaveBeenCalledTimes(2)
})
})
describe('handlePanStart / handlePanMove', () => {
@@ -345,6 +389,13 @@ 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)
@@ -401,6 +452,49 @@ 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)
@@ -418,6 +512,26 @@ 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', () => {

View File

@@ -73,4 +73,45 @@ 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')] })
node.widgets.push({
name: '$$comfy_animation_preview',
element: undefined as unknown as HTMLElement
})
useNodeAnimatedImage().showAnimatedPreview(node)
expect(node.widgets).toHaveLength(1)
})
})

View File

@@ -0,0 +1,430 @@
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 () => {
appState.graph.nodes = undefined as unknown 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()
})
})

View File

@@ -65,4 +65,29 @@ 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)
})
})

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
@@ -28,8 +28,24 @@ 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() {
images.push(this as unknown as HTMLImageElement)
}
}
vi.stubGlobal('Image', MockImage)
return images
}
async function setup() {
vi.clearAllMocks()
vi.useFakeTimers()
@@ -93,4 +109,103 @@ 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)
})
})

View File

@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -12,6 +15,7 @@ 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'
@@ -123,6 +127,35 @@ 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
// -----------------------------------------------------------------------------
@@ -189,6 +222,32 @@ 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(
@@ -222,6 +281,19 @@ 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(
@@ -238,6 +310,64 @@ 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(
@@ -468,6 +598,42 @@ 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()
@@ -541,6 +707,43 @@ 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 node = createMockNodeWithPriceBadge(
'TestUnsupportedEngineNode',
fromAny<PriceBadge, unknown>({
engine: 'literal',
expr: '{"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: []
}
})
)
expect(getNodeDisplayPrice(node)).toBe('')
})
})
describe('getNodePricingConfig', () => {
@@ -595,6 +798,107 @@ 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()
@@ -655,6 +959,20 @@ 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', () => {
@@ -743,6 +1061,16 @@ 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(
@@ -968,8 +1296,21 @@ 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 as never })
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
expect(result).toBe('')
})
})
@@ -999,6 +1340,14 @@ 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', () => {
@@ -1017,6 +1366,22 @@ 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', () => {
@@ -1024,6 +1389,11 @@ 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', () => {
@@ -1168,6 +1538,20 @@ 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',
@@ -1190,6 +1574,29 @@ 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',
@@ -1265,6 +1672,30 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -0,0 +1,89 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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 fromAny({
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 graphNode = node([])
const { showTextPreview } = useNodeProgressText()
showTextPreview(graphNode, 'queued')
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
name: '$$node-text-preview',
type: 'progressText'
})
expect(mockTextPreviewWidget.mock.results[0].value.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 = fromAny<IBaseWidget, unknown>({
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)
})
})

View File

@@ -1,4 +1,4 @@
import { describe, expect, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -6,26 +6,34 @@ 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: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
useColorPaletteStore: () => mockPalette
}))
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
const { updateSubgraphCredits, getCreditsBadge, isCreditsBadge } =
usePriceBadge()
const mockNode = new LGraphNode('mock node')
mockNode.badges = [getCreditsBadge('$0.05/Run')]
@@ -36,6 +44,34 @@ 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 }) => {

View File

@@ -151,7 +151,9 @@ describe('useComputedWithWidgetWatch', () => {
})
it('should handle nodes without widgets gracefully', () => {
const mockNode = createMockNode([])
const mockNode = Object.assign(createMockLGraphNode(), {
widgets: undefined
}) as LGraphNode
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
@@ -160,6 +162,85 @@ 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([

View File

@@ -12,8 +12,12 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const container = shallowRef<HTMLDivElement | null>(
document.createElement('div')
)
const picker = shallowRef<HTMLInputElement | null>(
document.createElement('input')
)
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
@@ -51,6 +55,19 @@ 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({ target: { value: '#222222' } } as unknown as Event)
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())
@@ -100,6 +117,82 @@ 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')

View File

@@ -0,0 +1,61 @@
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()
})
})

View File

@@ -0,0 +1,48 @@
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)
})
})

View File

@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useTreeFolderOperations } from './useTreeFolderOperations'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
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 = 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 = 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 = 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 = 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 = 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 = 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 = 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')
})
})

View File

@@ -0,0 +1,285 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
type DropInput = {
clientX: number
clientY: number
}
type DropEvent = {
location: { current: { input: DropInput } }
source: { data: { type: string; data?: unknown } }
}
type DroppableOptions = {
getDropEffect: (
args: DropEvent
) => Exclude<DataTransfer['dropEffect'], 'none'>
onDrop: (event: DropEvent) => Promise<void>
}
const {
MockComfyModelDef,
MockComfyNodeDefImpl,
MockComfyWorkflow,
captured,
graph,
insertWorkflow,
addNodeOnGraph,
getNodeProvider,
getAllNodeProviders,
withNodeAddSource
} = vi.hoisted(() => {
class MockComfyNodeDefImpl {
name: string
constructor(name = 'NodeDef') {
this.name = name
}
}
class MockComfyModelDef {
directory: string
file_name: string
constructor(directory = 'checkpoints', fileName = 'model.safetensors') {
this.directory = directory
this.file_name = fileName
}
}
class MockComfyWorkflow {
id: string
constructor(id = 'workflow') {
this.id = id
}
}
return {
MockComfyModelDef,
MockComfyNodeDefImpl,
MockComfyWorkflow,
captured: {
options: undefined as DroppableOptions | undefined
},
graph: {
getNodeOnPos: vi.fn()
},
insertWorkflow: vi.fn(),
addNodeOnGraph: vi.fn(),
getNodeProvider: vi.fn(),
getAllNodeProviders: vi.fn(),
withNodeAddSource: vi.fn((_source: string, callback: () => unknown) =>
callback()
)
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: ([x, y]: [number, number]) => [x / 2, y / 2]
})
}))
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDroppable: vi.fn(
(_target: () => HTMLCanvasElement | null, options: DroppableOptions) => {
captured.options = options
}
)
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: { NODE_TITLE_HEIGHT: 24 }
}))
vi.mock('@/platform/telemetry/nodeAdded/nodeAddSource', () => ({
withNodeAddSource
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({ insertWorkflow })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: MockComfyWorkflow
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph } }
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph })
}))
vi.mock('@/stores/modelStore', () => ({
ComfyModelDef: MockComfyModelDef
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getNodeProvider,
getAllNodeProviders
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
ComfyNodeDefImpl: MockComfyNodeDefImpl
}))
function dropEvent(
data: unknown,
input: DropInput = { clientX: 20, clientY: 40 }
) {
return {
location: { current: { input } },
source: { data: { type: 'tree-explorer-node', data: { data } } }
}
}
function options() {
useCanvasDrop(ref(document.createElement('canvas')))
const value = captured.options
if (!value) throw new Error('droppable options were not registered')
return value
}
beforeEach(() => {
captured.options = undefined
graph.getNodeOnPos.mockReset()
insertWorkflow.mockReset()
addNodeOnGraph.mockReset()
getNodeProvider.mockReset()
getAllNodeProviders.mockReset()
withNodeAddSource.mockClear()
})
describe('useCanvasDrop', () => {
it('uses copy effect only for tree explorer nodes', () => {
const droppable = options()
expect(
droppable.getDropEffect({
...dropEvent(undefined),
source: { data: { type: 'tree-explorer-node' } }
})
).toBe('copy')
expect(
droppable.getDropEffect({
...dropEvent(undefined),
source: { data: { type: 'other' } }
})
).toBe('move')
})
it('adds dropped node definitions below the cursor', async () => {
const nodeDef = new MockComfyNodeDefImpl('KSampler')
const droppable = options()
await droppable.onDrop(dropEvent(nodeDef))
expect(withNodeAddSource).toHaveBeenCalledWith(
'sidebar_drag',
expect.any(Function)
)
expect(addNodeOnGraph).toHaveBeenCalledWith(nodeDef, {
pos: [10, 44]
})
})
it('ignores drops that do not come from tree explorer nodes', async () => {
const nodeDef = new MockComfyNodeDefImpl('KSampler')
const droppable = options()
await droppable.onDrop({
...dropEvent(nodeDef),
source: { data: { type: 'other', data: { data: nodeDef } } }
})
expect(addNodeOnGraph).not.toHaveBeenCalled()
expect(insertWorkflow).not.toHaveBeenCalled()
})
it('sets a model widget on an existing compatible node', async () => {
const widget = { name: 'ckpt_name', value: '' }
const node = { comfyClass: 'CheckpointLoaderSimple', widgets: [widget] }
const provider = {
key: 'ckpt_name',
nodeDef: { name: 'CheckpointLoaderSimple' }
}
graph.getNodeOnPos.mockReturnValue(node)
getAllNodeProviders.mockReturnValue([provider])
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
)
expect(widget.value).toBe('dream.safetensors')
expect(addNodeOnGraph).not.toHaveBeenCalled()
})
it('creates a provider node when the model has no compatible target', async () => {
const widget = { name: 'lora_name', value: '' }
const createdNode = { widgets: [widget] }
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
graph.getNodeOnPos.mockReturnValue(undefined)
getNodeProvider.mockReturnValue(provider)
addNodeOnGraph.mockReturnValue(createdNode)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
)
expect(addNodeOnGraph).toHaveBeenCalledWith(provider.nodeDef, {
pos: [10, 20]
})
expect(widget.value).toBe('style.safetensors')
})
it('does nothing for model drops without a compatible or default provider', async () => {
graph.getNodeOnPos.mockReturnValue({ comfyClass: 'OtherNode' })
getAllNodeProviders.mockReturnValue([
{ key: 'ckpt_name', nodeDef: { name: 'CheckpointLoaderSimple' } }
])
getNodeProvider.mockReturnValue(null)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
)
expect(addNodeOnGraph).not.toHaveBeenCalled()
})
it('does not set a model value when the target node lacks the provider widget', async () => {
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
const createdNode = { widgets: [{ name: 'other', value: '' }] }
graph.getNodeOnPos.mockReturnValue(undefined)
getNodeProvider.mockReturnValue(provider)
addNodeOnGraph.mockReturnValue(createdNode)
const droppable = options()
await droppable.onDrop(
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
)
expect(createdNode.widgets[0].value).toBe('')
})
it('inserts dropped workflows at the canvas position', async () => {
const workflow = new MockComfyWorkflow('wf-1')
const droppable = options()
await droppable.onDrop(dropEvent(workflow))
expect(insertWorkflow).toHaveBeenCalledWith(workflow, {
position: [10, 20]
})
})
})

View File

@@ -0,0 +1,250 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IContextMenuOptions } from '@/lib/litegraph/src/interfaces'
const mockInstall = vi.hoisted(() => vi.fn())
const mockRegisterWrapper = vi.hoisted(() => vi.fn())
const mockExtractLegacyItems = vi.hoisted(() => vi.fn())
const mockCollectCanvasMenuItems = vi.hoisted(() => vi.fn())
const mockCollectNodeMenuItems = vi.hoisted(() => vi.fn())
const mockSt = vi.hoisted(() => vi.fn())
const mockTe = vi.hoisted(() => vi.fn())
const mockContextMenuConstructor = vi.hoisted(() => vi.fn())
const mockClasses = vi.hoisted(() => {
class LGraphCanvas {
getCanvasMenuOptions() {
return [{ content: 'Base' }, null]
}
getNodeMenuOptions(node: unknown) {
return [{ content: `Node:${String(node)}` }]
}
}
class ContextMenu {
constructor(values: unknown, options: unknown) {
mockContextMenuConstructor(values, options)
}
}
return {
LGraphCanvas,
ContextMenu,
LiteGraph: {
ContextMenu
}
}
})
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
legacyMenuCompat: {
install: mockInstall,
registerWrapper: mockRegisterWrapper,
extractLegacyItems: mockExtractLegacyItems
}
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LGraphCanvas: mockClasses.LGraphCanvas,
LiteGraph: mockClasses.LiteGraph
}))
vi.mock('@/scripts/app', () => ({
app: {
collectCanvasMenuItems: mockCollectCanvasMenuItems,
collectNodeMenuItems: mockCollectNodeMenuItems
}
}))
vi.mock('@/i18n', () => ({
st: mockSt,
te: mockTe
}))
vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: (value: string) => `normalized-${value}`
}))
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useContextMenuTranslation } from './useContextMenuTranslation'
describe('useContextMenuTranslation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollectCanvasMenuItems.mockReturnValue([{ content: 'NewApi' }])
mockCollectNodeMenuItems.mockReturnValue([{ content: 'NodeApi' }])
mockExtractLegacyItems.mockReturnValue([{ content: 'Legacy' }])
mockSt.mockImplementation((_key: string, fallback: string) => {
return `translated:${fallback}`
})
mockTe.mockImplementation(
(key: string) => key === 'contextMenu.TranslateMe'
)
mockClasses.LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base' }, null]
}
mockClasses.LGraphCanvas.prototype.getNodeMenuOptions = function (
node: unknown
) {
return [{ content: `Node:${String(node)}` }]
}
mockClasses.LiteGraph.ContextMenu = mockClasses.ContextMenu
})
it('wraps canvas menu options with new API, legacy, and translated items', () => {
useContextMenuTranslation()
const canvas = new mockClasses.LGraphCanvas()
const result = canvas.getCanvasMenuOptions()
expect(mockInstall).toHaveBeenCalledWith(
mockClasses.LGraphCanvas.prototype,
'getCanvasMenuOptions'
)
expect(mockCollectCanvasMenuItems).toHaveBeenCalledWith(canvas)
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
'getCanvasMenuOptions',
canvas
)
expect(result).toEqual([
{ content: 'translated:Base' },
null,
{ content: 'translated:NewApi' },
{ content: 'translated:Legacy' }
])
})
it('wraps node menu options with new API and legacy extension items', () => {
useContextMenuTranslation()
const canvas = new mockClasses.LGraphCanvas()
const result = canvas.getNodeMenuOptions('node')
expect(mockInstall).toHaveBeenCalledWith(
mockClasses.LGraphCanvas.prototype,
'getNodeMenuOptions'
)
expect(mockCollectNodeMenuItems).toHaveBeenCalledWith('node')
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
'getNodeMenuOptions',
canvas,
'node'
)
expect(result).toEqual([
{ content: 'Node:node' },
{ content: 'NodeApi' },
{ content: 'Legacy' }
])
})
it('translates LiteGraph context menu titles, nested items, and conversion labels', () => {
useContextMenuTranslation()
const values = [
{ content: 'TranslateMe' },
{ content: 'Convert seed to input' },
{ content: 'Convert value to widget' },
{
content: '',
submenu: {
options: [{ content: 'TranslateMe' }]
}
},
'separator'
]
const options = {
title: 'KSampler',
extra: {
inputs: [{ name: 'seed', label: 'Seed Label' }],
widgets: [{ name: 'value', label: 'Value Label' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(options.title).toBe('translated:KSampler')
expect(values).toMatchObject([
{ content: 'translated:TranslateMe' },
{ content: 'translated:Convert Seed Labeltranslated: to input' },
{ content: 'translated:Convert Value Labeltranslated: to widget' },
{
submenu: {
options: [{ content: 'translated:TranslateMe' }]
}
},
'separator'
])
expect(mockContextMenuConstructor).toHaveBeenCalledWith(values, options)
})
it('uses parent menu extra data when direct options do not provide it', () => {
useContextMenuTranslation()
const values = [{ content: 'Convert latent to input' }]
const options = {
parentMenu: {
options: {
extra: {
inputs: [{ name: 'latent', label: 'Latent Label' }]
}
}
}
}
new LiteGraph.ContextMenu(
values,
fromAny<IContextMenuOptions<unknown, unknown>, unknown>(options)
)
expect(values[0].content).toBe(
'translated:Convert Latent Labeltranslated: to input'
)
})
it('keeps conversion names when matching inputs and widgets have no label', () => {
useContextMenuTranslation()
const values = [
{ content: 'Convert seed to input' },
{ content: 'Convert value to widget' }
]
const options = {
extra: {
inputs: [{ name: 'seed' }],
widgets: [{ name: 'value' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(values).toMatchObject([
{ content: 'translated:Convert seedtranslated: to input' },
{ content: 'translated:Convert valuetranslated: to widget' }
])
})
it('uses widget labels when input conversion names do not match inputs', () => {
useContextMenuTranslation()
const values = [{ content: 'Convert seed to input' }]
const options = {
extra: {
inputs: [{ name: 'other' }],
widgets: [{ name: 'seed', label: 'Widget Seed' }]
}
}
new LiteGraph.ContextMenu(values, options)
expect(values[0].content).toBe(
'translated:Convert Widget Seedtranslated: to input'
)
})
it('keeps plain unregistered menu items unchanged', () => {
useContextMenuTranslation()
const values = [{ content: 'Plain' }]
new LiteGraph.ContextMenu(values, {})
expect(values[0].content).toBe('Plain')
})
})

View File

@@ -0,0 +1,124 @@
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { DIALOG_KEY, useEditKeybindingDialog } from './useEditKeybindingDialog'
const mockShowSmallLayoutDialog = vi.fn()
const mockGetKeybinding = vi.fn()
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSmallLayoutDialog: mockShowSmallLayoutDialog
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybinding: mockGetKeybinding
})
}))
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue',
() => ({
default: { name: 'EditKeybindingContent' }
})
)
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue',
() => ({
default: { name: 'EditKeybindingFooter' }
})
)
vi.mock(
'@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue',
() => ({
default: { name: 'EditKeybindingHeader' }
})
)
function makeCombo(label: string): KeyComboImpl {
return fromAny({
label,
equals: vi.fn((other: { label: string }) => other.label === label)
})
}
describe('useEditKeybindingDialog', () => {
it('opens the edit dialog with default edit state', () => {
const currentCombo = makeCombo('Ctrl+A')
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo
})
expect(mockShowSmallLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: DIALOG_KEY,
props: expect.objectContaining({
commandLabel: 'Test command'
})
})
)
const dialog = mockShowSmallLayoutDialog.mock.calls[0][0]
expect(dialog.props.dialogState).toMatchObject({
commandId: 'app.test',
newCombo: currentCombo,
currentCombo,
mode: 'edit',
existingBinding: null
})
expect(dialog.footerProps.dialogState).toBe(dialog.props.dialogState)
})
it('updates combo state and reports a conflicting binding', () => {
const currentCombo = makeCombo('Ctrl+A')
const newCombo = makeCombo('Ctrl+B')
const binding = fromAny<KeybindingImpl, unknown>({
commandId: 'other.command'
})
mockGetKeybinding.mockReturnValue(binding)
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo,
mode: 'add',
existingBinding: binding
})
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
dialog.props.onUpdateCombo(newCombo)
expect(dialog.props.dialogState.newCombo).toMatchObject({
label: 'Ctrl+B'
})
expect(dialog.props.existingKeybindingOnCombo.value).toBe(binding)
expect(mockGetKeybinding.mock.calls.at(-1)?.[0]).toMatchObject({
label: 'Ctrl+B'
})
})
it('does not report a conflict for an unchanged or empty combo', () => {
const currentCombo = makeCombo('Ctrl+A')
useEditKeybindingDialog().show({
commandId: 'app.test',
commandLabel: 'Test command',
currentCombo
})
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
dialog.props.dialogState.newCombo = null
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
})
})

View File

@@ -6,6 +6,12 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
// Mock the API module
@@ -24,6 +30,14 @@ vi.mock('@/platform/distribution/types', () => ({
describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
localStorage.clear()
remoteConfig.value = {}
remoteConfigState.value = 'unloaded'
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
vi.mocked(distributionTypes).isCloud = false
vi.mocked(distributionTypes).isNightly = false
})
describe('flags object', () => {
@@ -219,6 +233,149 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('teamWorkspacesEnabled is disabled outside cloud builds', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
})
it('teamWorkspacesEnabled uses the cached value before authenticated config loads', () => {
vi.mocked(distributionTypes).isCloud = true
cachedTeamWorkspacesEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('teamWorkspacesEnabled falls back to the server after authenticated config loads', () => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'authenticated'
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
false
)
})
it('nodeLibraryEssentialsEnabled checks config outside nightly and dev builds', () => {
vi.stubEnv('DEV', false)
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
})
it('nodeLibraryEssentialsEnabled uses remote config before the server fallback', () => {
vi.stubEnv('DEV', false)
remoteConfig.value = {
node_library_essentials_enabled: true
}
const { flags } = useFeatureFlags()
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
})
})
describe('auth-gated flags on cloud', () => {
beforeEach(() => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {}
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
})
describe('signupTurnstileMode', () => {

View File

@@ -1,7 +1,9 @@
import { computed, reactive, readonly } from 'vue'
import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -30,6 +32,7 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -46,6 +49,26 @@ function resolveFlag<T>(
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
* Cloud build it is always false; during the auth window it falls back to the
* cached session value so anonymous bootstrap config cannot route the user to
* the wrong backend before authenticated config confirms the flag.
*/
function resolveAuthGatedFlag(
flagKey: string,
remoteConfigValue: boolean | undefined,
cachedValue: Ref<boolean | undefined>
): boolean {
const override = getDevOverride<boolean>(flagKey)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
}
/**
* Composable for reactive access to server-side feature flags
*/
@@ -104,18 +127,10 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
return resolveAuthGatedFlag(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
remoteConfig.value.team_workspaces_enabled,
cachedTeamWorkspacesEnabled
)
},
get userSecretsEnabled() {
@@ -175,6 +190,18 @@ export function useFeatureFlags() {
false
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -0,0 +1,591 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, nextTick } from 'vue'
import type { App } from 'vue'
const mockStats = {
min: 0,
max: 4,
mean: 1,
stdDev: 0.5,
nanCount: 0,
infCount: 0
}
const mockHistograms = {
r: new Uint32Array([1]),
g: new Uint32Array([2]),
b: new Uint32Array([3]),
a: new Uint32Array([4]),
luminance: new Uint32Array([5])
}
interface MockTexture {
type: string
image: {
width: number
height: number
data: ArrayLike<number>
}
colorSpace?: string
minFilter?: string
magFilter?: string
needsUpdate?: boolean
dispose: ReturnType<typeof vi.fn>
}
const mocks = vi.hoisted(() => ({
exrLoad: vi.fn(),
exrSetDataType: vi.fn(),
rgbeLoad: vi.fn(),
render: vi.fn(),
setPixelRatio: vi.fn(),
setClearColor: vi.fn(),
setSize: vi.fn(),
updateProjectionMatrix: vi.fn(),
positionSet: vi.fn(),
scaleSet: vi.fn(),
sceneAdd: vi.fn(),
materialDispose: vi.fn(),
geometryDispose: vi.fn(),
viewportObserveResize: vi.fn(),
viewportDisposeRenderer: vi.fn(),
textureDispose: vi.fn(),
raycasterSetFromCamera: vi.fn(),
intersectObject: vi.fn(),
fromHalfFloat: vi.fn((value: number) => value + 0.5),
matrixSet: vi.fn(),
detectGamutFromChromaticities: vi.fn(() => 'Display P3'),
gamutToSrgbMatrix: vi.fn(() => [1, 0, 0, 0, 1, 0, 0, 0, 1]),
computeImageStats: vi.fn(() => mockStats),
computeChannelHistograms: vi.fn(() => mockHistograms),
lastCanvas: undefined as HTMLCanvasElement | undefined
}))
vi.mock('three', () => {
class WebGLRenderer {
domElement: HTMLCanvasElement
outputColorSpace?: string
constructor() {
const canvas = document.createElement('canvas')
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) satisfies DOMRect
this.domElement = canvas
mocks.lastCanvas = canvas
}
setPixelRatio(value: number) {
mocks.setPixelRatio(value)
}
setClearColor(color: number, alpha: number) {
mocks.setClearColor(color, alpha)
}
setSize(width: number, height: number, updateStyle: boolean) {
mocks.setSize(width, height, updateStyle)
}
render(scene: unknown, camera: unknown) {
mocks.render(scene, camera)
}
}
class Scene {
add(mesh: unknown) {
mocks.sceneAdd(mesh)
}
}
class OrthographicCamera {
left = -1
right = 1
top = 1
bottom = -1
zoom = 1
position = {
x: 0,
y: 0,
z: 1,
set: (x: number, y: number, z: number) => {
this.position.x = x
this.position.y = y
this.position.z = z
mocks.positionSet(x, y, z)
}
}
updateProjectionMatrix() {
mocks.updateProjectionMatrix()
}
}
class Matrix3 {
set(...values: number[]) {
mocks.matrixSet(values)
}
}
class ShaderMaterial {
uniforms: Record<string, { value: unknown }>
constructor(options: { uniforms: Record<string, { value: unknown }> }) {
this.uniforms = options.uniforms
}
dispose() {
mocks.materialDispose()
}
}
class Mesh {
scale = { set: mocks.scaleSet }
geometry = { dispose: mocks.geometryDispose }
constructor(
readonly geometryInput: unknown,
readonly materialInput: unknown
) {}
}
class PlaneGeometry {
constructor(
readonly width: number,
readonly height: number
) {}
}
class Raycaster {
setFromCamera(pointer: unknown, camera: unknown) {
mocks.raycasterSetFromCamera(pointer, camera)
}
intersectObject(mesh: unknown) {
return mocks.intersectObject(mesh)
}
}
class Vector2 {
constructor(
public x = 0,
public y = 0
) {}
}
return {
WebGLRenderer,
Scene,
OrthographicCamera,
Matrix3,
ShaderMaterial,
Mesh,
PlaneGeometry,
Raycaster,
Vector2,
DataUtils: { fromHalfFloat: mocks.fromHalfFloat },
MathUtils: {
clamp: (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
},
FloatType: 'FloatType',
HalfFloatType: 'HalfFloatType',
LinearSRGBColorSpace: 'LinearSRGBColorSpace',
LinearFilter: 'LinearFilter',
GLSL3: 'GLSL3'
}
})
vi.mock('three/examples/jsm/loaders/EXRLoader', () => ({
EXRLoader: class {
setDataType(type: string) {
mocks.exrSetDataType(type)
}
load(
url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void,
onProgress: unknown,
onError: (error: unknown) => void
) {
mocks.exrLoad(url, onLoad, onProgress, onError)
}
}
}))
vi.mock('three/examples/jsm/loaders/RGBELoader', () => ({
RGBELoader: class {
load(
url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void,
onProgress: unknown,
onError: (error: unknown) => void
) {
mocks.rgbeLoad(url, onLoad, onProgress, onError)
}
}
}))
vi.mock('@/renderer/three/WebGLViewport', () => ({
WebGLViewport: class {
constructor(readonly renderer: unknown) {}
observeResize(container: HTMLElement, resize: () => void) {
mocks.viewportObserveResize(container, resize)
}
disposeRenderer() {
mocks.viewportDisposeRenderer()
}
}
}))
vi.mock('@/renderer/hdr/colorGamut', () => ({
detectGamutFromChromaticities: mocks.detectGamutFromChromaticities,
gamutToSrgbMatrix: mocks.gamutToSrgbMatrix
}))
vi.mock('@/renderer/hdr/hdrStats', () => ({
computeImageStats: mocks.computeImageStats,
computeChannelHistograms: mocks.computeChannelHistograms
}))
import { CHANNEL_MODES, useHdrViewer } from './useHdrViewer'
type HdrViewer = ReturnType<typeof useHdrViewer>
let mountedApps: App[] = []
function createViewer(): HdrViewer {
let viewer: HdrViewer | undefined
const app = createApp(
defineComponent({
setup() {
viewer = useHdrViewer()
return () => null
}
})
)
app.mount(document.createElement('div'))
mountedApps.push(app)
if (!viewer) throw new Error('Expected useHdrViewer to initialize')
return viewer
}
function makeTexture(
data: ArrayLike<number> = [0, 0.25, 0.5, 1, 1, 2, 3, 4],
width = 2,
height = 1,
type = 'FloatType'
): MockTexture {
return {
type,
image: { width, height, data },
dispose: mocks.textureDispose
}
}
function makeContainer(): HTMLElement {
const container = document.createElement('div')
Object.defineProperty(container, 'clientWidth', { value: 200 })
Object.defineProperty(container, 'clientHeight', { value: 100 })
return container
}
describe('useHdrViewer', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
mocks.lastCanvas = undefined
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture())
)
mocks.rgbeLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture(), { header: { chromaticities: {} } })
)
mocks.intersectObject.mockReturnValue([])
})
afterEach(() => {
for (const app of mountedApps) app.unmount()
mountedApps = []
vi.unstubAllGlobals()
})
it('exposes all channel modes', () => {
expect(CHANNEL_MODES).toEqual(['rgb', 'r', 'g', 'b', 'a', 'luminance'])
})
it('mounts hdr textures through the RGBE loader and exposes image metadata', async () => {
const viewer = createViewer()
const container = makeContainer()
await viewer.mount(container, '/api/view?filename=scene.hdr')
expect(mocks.rgbeLoad).toHaveBeenCalledWith(
'/api/view?filename=scene.hdr',
expect.any(Function),
undefined,
expect.any(Function)
)
expect(mocks.exrSetDataType).not.toHaveBeenCalled()
expect(viewer.loading.value).toBe(false)
expect(viewer.error.value).toBeNull()
expect(viewer.gamut.value).toBe('Display P3')
expect(viewer.dimensions.value).toBe('2 x 1')
expect(viewer.stats.value).toEqual(mockStats)
expect(viewer.histogram.value).toBe(mockHistograms.luminance)
viewer.channel.value = 'g'
await nextTick()
expect(viewer.histogram.value).toBe(mockHistograms.g)
expect(container.contains(mocks.lastCanvas!)).toBe(true)
})
it('selects histograms for every channel mode', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.hdr')
for (const [mode, histogram] of [
['r', mockHistograms.r],
['g', mockHistograms.g],
['b', mockHistograms.b],
['a', mockHistograms.a],
['rgb', mockHistograms.luminance],
['luminance', mockHistograms.luminance]
] as const) {
viewer.channel.value = mode
await nextTick()
expect(viewer.histogram.value).toBe(histogram)
}
})
it('loads exr textures with float data and reads hovered pixels', async () => {
const data = [0, 1, 2, 3, 4, 5, 6, 7]
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture(data, 2, 1, 'HalfFloatType'))
)
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
)
expect(mocks.exrSetDataType).toHaveBeenCalledWith('FloatType')
expect(mocks.fromHalfFloat).toHaveBeenCalled()
expect(viewer.pixel.value).toEqual({
x: 1,
y: 0,
r: 4.5,
g: 5.5,
b: 6.5,
a: 7.5
})
})
it('reads three-channel pixels without alpha', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => onLoad(makeTexture([0, 1, 2, 3, 4, 5], 2, 1))
)
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
)
expect(viewer.pixel.value).toEqual({
x: 1,
y: 0,
r: 3,
g: 4,
b: 5,
a: null
})
})
it('clears the hovered pixel when the pointer leaves or misses the mesh', async () => {
mocks.intersectObject.mockReturnValueOnce([{ uv: { x: 0, y: 0 } }])
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
expect(viewer.pixel.value).not.toBeNull()
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointerleave'))
expect(viewer.pixel.value).toBeNull()
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
expect(viewer.pixel.value).toBeNull()
})
it('normalizes exposure and disposes renderer resources', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
viewer.normalizeExposure()
viewer.dispose()
expect(viewer.exposureStops.value).toBe(-2)
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
expect(mocks.textureDispose).toHaveBeenCalled()
expect(mocks.materialDispose).toHaveBeenCalled()
expect(mocks.geometryDispose).toHaveBeenCalled()
})
it('handles no-op viewer actions before mounting', () => {
const viewer = createViewer()
viewer.fitView()
viewer.normalizeExposure()
viewer.dispose()
expect(viewer.exposureStops.value).toBe(0)
expect(mocks.viewportDisposeRenderer).not.toHaveBeenCalled()
})
it('leaves sample-derived state empty when texture data is missing', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) =>
onLoad({
...makeTexture(),
image: {
width: 2,
height: 1,
data: undefined as unknown as ArrayLike<number>
}
})
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
viewer.normalizeExposure()
expect(viewer.dimensions.value).toBe('2 x 1')
expect(viewer.stats.value).toBeNull()
expect(viewer.histogram.value).toBeNull()
expect(viewer.exposureStops.value).toBe(0)
})
it('disposes textures that finish loading after viewer disposal', async () => {
let resolveLoad: (
texture: MockTexture,
textureData?: unknown
) => void = () => {}
mocks.exrLoad.mockImplementation(
(
_url: string,
onLoad: (texture: MockTexture, textureData?: unknown) => void
) => {
resolveLoad = onLoad
}
)
const viewer = createViewer()
const mounting = viewer.mount(
makeContainer(),
'/api/view?filename=scene.exr'
)
viewer.dispose()
resolveLoad(makeTexture())
await mounting
expect(mocks.textureDispose).toHaveBeenCalled()
})
it('reports loader errors and clears loading state', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
_onProgress: unknown,
onError: (error: unknown) => void
) => onError(new Error('load failed'))
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
expect(viewer.error.value).toBe('load failed')
expect(viewer.loading.value).toBe(false)
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
})
it('reports string loader errors', async () => {
mocks.exrLoad.mockImplementation(
(
_url: string,
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
_onProgress: unknown,
onError: (error: unknown) => void
) => onError('load failed')
)
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
expect(viewer.error.value).toBe('load failed')
})
it('zooms with the wheel and pans while dragging', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000 }))
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
window.dispatchEvent(
new PointerEvent('pointermove', { clientX: 20, clientY: 30 })
)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(mocks.updateProjectionMatrix).toHaveBeenCalled()
expect(mocks.render).toHaveBeenCalled()
})
it('ignores hover sampling while dragging', async () => {
const viewer = createViewer()
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
)
mocks.raycasterSetFromCamera.mockClear()
mocks.lastCanvas!.dispatchEvent(
new PointerEvent('pointermove', { clientX: 20, clientY: 20 })
)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(mocks.raycasterSetFromCamera).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent } from 'vue'
const mockSettingGet = vi.hoisted(() => vi.fn())
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
const mockReleaseStore = vi.hoisted(() => ({
shouldShowRedDot: { value: false },
initialize: vi.fn()
}))
const mockHelpCenterStore = vi.hoisted(() => ({
isVisible: { value: false },
toggle: vi.fn(),
hide: vi.fn()
}))
const mockConflictDetection = vi.hoisted(() => ({
shouldShowConflictModalAfterUpdate: vi.fn()
}))
const mockShowNodeConflictDialog = vi.hoisted(() => vi.fn())
const mockConflictAcknowledgment = vi.hoisted(() => ({
shouldShowRedDot: { value: false },
markConflictsAsSeen: vi.fn()
}))
vi.mock('pinia', () => ({
storeToRefs: (store: Record<string, unknown>) => store
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockSettingGet })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => mockReleaseStore
}))
vi.mock('@/stores/helpCenterStore', () => ({
useHelpCenterStore: () => mockHelpCenterStore
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => mockConflictDetection
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
() => ({
useNodeConflictDialog: () => ({ show: mockShowNodeConflictDialog })
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => mockConflictAcknowledgment
})
)
import { useHelpCenter } from './useHelpCenter'
function mountHelpCenter() {
let result: ReturnType<typeof useHelpCenter> | undefined
const app = createApp(
defineComponent({
setup() {
result = useHelpCenter()
return () => null
}
})
)
app.mount(document.createElement('div'))
if (!result) throw new Error('Expected help center composable to initialize')
return { app, result }
}
describe('useHelpCenter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingGet.mockReturnValue('left')
mockReleaseStore.shouldShowRedDot.value = false
mockHelpCenterStore.isVisible.value = false
mockHelpCenterStore.toggle.mockImplementation(() => {
mockHelpCenterStore.isVisible.value = !mockHelpCenterStore.isVisible.value
})
mockHelpCenterStore.hide.mockImplementation(() => {
mockHelpCenterStore.isVisible.value = false
})
mockConflictAcknowledgment.shouldShowRedDot.value = false
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
false
)
})
it('initializes releases on mount and exposes store-backed computed state', async () => {
mockReleaseStore.shouldShowRedDot.value = true
const { app, result } = mountHelpCenter()
expect(mockReleaseStore.initialize).toHaveBeenCalledOnce()
expect(result.isHelpCenterVisible.value).toBe(false)
expect(result.shouldShowRedDot.value).toBe(true)
expect(result.sidebarLocation.value).toBe('left')
app.unmount()
})
it('uses the conflict red dot when the release red dot is hidden', () => {
mockConflictAcknowledgment.shouldShowRedDot.value = true
const { app, result } = mountHelpCenter()
expect(result.shouldShowRedDot.value).toBe(true)
app.unmount()
})
it('tracks and toggles help center visibility', () => {
const { app, result } = mountHelpCenter()
result.toggleHelpCenter()
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
expect(mockHelpCenterStore.toggle).toHaveBeenCalledOnce()
expect(mockHelpCenterStore.isVisible.value).toBe(true)
result.closeHelpCenter()
expect(mockHelpCenterStore.hide).toHaveBeenCalledOnce()
expect(mockHelpCenterStore.isVisible.value).toBe(false)
app.unmount()
})
it('opens the conflict modal after the whats-new dialog when needed', async () => {
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
true
)
const { app, result } = mountHelpCenter()
await result.handleWhatsNewDismissed()
expect(mockShowNodeConflictDialog).toHaveBeenCalledWith({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: expect.any(Function)
}
})
const options = mockShowNodeConflictDialog.mock.calls[0][0]
options.dialogComponentProps.onClose()
expect(
mockConflictAcknowledgment.markConflictsAsSeen
).toHaveBeenCalledOnce()
app.unmount()
})
it('does not open the conflict modal when not needed', async () => {
const { app, result } = mountHelpCenter()
await result.handleWhatsNewDismissed()
expect(mockShowNodeConflictDialog).not.toHaveBeenCalled()
app.unmount()
})
it('logs conflict-check failures without throwing', async () => {
const error = new Error('failed')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockRejectedValue(
error
)
const { app, result } = mountHelpCenter()
await expect(result.handleWhatsNewDismissed()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[HelpCenter] Error checking conflict modal:',
error
)
app.unmount()
})
})

View File

@@ -137,7 +137,7 @@ function mountContainerLayout(
function makePointerEvent(
type: 'pointerdown' | 'pointermove' | 'pointerup',
target: HTMLElement,
target: EventTarget,
clientX: number,
clientY: number
) {
@@ -302,6 +302,32 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBeNull()
})
it('returns null when a subgraph output link cannot be resolved', async () => {
const subgraphInput = createMockSubgraphNode([], {
id: 40,
resolveSubgraphOutputLink: vi.fn(() => undefined)
})
const sgCrop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => subgraphInput),
getInputLink: vi.fn(() => ({ origin_slot: 0 })),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(sgCrop)
const vm = await mountHarness()
expect(vm.imageUrl).toBeNull()
})
it('returns null when the source node has no image URLs', async () => {
mockGetNodeImageUrls.mockImplementation((n) =>
n === sourceNode ? [] : null
)
const vm = await mountHarness()
expect(vm.imageUrl).toBeNull()
})
it('resolves image through a subgraph input node', async () => {
const innerSource = createMockLGraphNode({
id: 50,
@@ -340,6 +366,18 @@ describe('useImageCrop', () => {
expect(vm.imageUrl).toBe('https://example.com/b.png')
})
it('keeps loading unchanged when output updates keep the same URL', async () => {
const vm = await mountHarness()
;(vm.handleImageLoad as () => void)()
expect(vm.isLoading).toBe(false)
outputStore.nodeOutputs['touch'] = { updated: true }
await flushTicks()
expect(vm.imageUrl).toBe('https://example.com/a.png')
expect(vm.isLoading).toBe(false)
})
it('updates imageUrl when nodePreviewImages change', async () => {
let url = 'https://example.com/a.png'
mockGetNodeImageUrls.mockImplementation((n) =>
@@ -390,6 +428,30 @@ describe('useImageCrop', () => {
expect(parseFloat(style.height)).toBeCloseTo(80, 1)
})
it('ignores resize observer callbacks before an image is rendered', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
flushResizeObservers()
expect(vm.imageUrl).toBeNull()
expect(vm.cropBoxStyle).toEqual({
left: '38px',
top: '38px',
width: '160px',
height: '120px'
})
})
it('uses default crop dimensions when model dimensions are zero', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 0, y: 0, width: 0, height: 0 }
expect(vm.cropWidth).toBe(512)
expect(vm.cropHeight).toBe(512)
})
it('exposes eight resize handles when unlocked and four when locked', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
@@ -423,6 +485,48 @@ describe('useImageCrop', () => {
expect(vm.isLoading).toBe(false)
})
it('uses fallback scale when dragging before image dimensions are known', async () => {
const vm = await mountHarness()
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
dragStart(makePointerEvent('pointerdown', captureEl, 10, 10))
dragMove(makePointerEvent('pointermove', captureEl, 30, 30))
dragEnd(makePointerEvent('pointerup', captureEl, 30, 30))
expect(vm.modelValue.x).toBe(0)
expect(vm.modelValue.y).toBe(0)
})
it('uses fallback scale when rendered container width is missing', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
mountContainerLayout(vm.$el, 0, 300, 0)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 130, 80), 'right')
resizeMove(makePointerEvent('pointermove', captureEl, 150, 80))
resizeEnd(makePointerEvent('pointerup', captureEl, 150, 80))
expect(vm.modelValue.width).toBe(140)
})
it('does not start dragging when there is no image', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
@@ -436,6 +540,41 @@ describe('useImageCrop', () => {
expect(vm.cropX as number).toBe(xBefore)
})
it('ignores drag move and end before dragging starts', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const releaseEl = document.createElement('div')
releaseEl.releasePointerCapture = vi.fn()
;(vm.handleDragMove as (e: PointerEvent) => void)(
makePointerEvent('pointermove', releaseEl, 260, 180)
)
;(vm.handleDragEnd as (e: PointerEvent) => void)(
makePointerEvent('pointerup', releaseEl, 260, 180)
)
expect(vm.modelValue).toEqual({ x: 10, y: 10, width: 120, height: 90 })
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
})
it('drags without pointer capture when the event target is not an element', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
dragStart(makePointerEvent('pointerdown', document, 200, 150))
dragMove(makePointerEvent('pointermove', document, 260, 180))
dragEnd(makePointerEvent('pointerup', document, 260, 180))
expect(vm.modelValue.x).toBeGreaterThan(10)
expect(vm.modelValue.y).toBeGreaterThan(10)
})
it('drags the crop box in image space and ends on pointerup', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 300)
@@ -506,6 +645,62 @@ describe('useImageCrop', () => {
expect(vm.modelValue.height).toBeLessThan(200)
})
it('resizes from the left edge and clamps to the image origin', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 500, 500)
vm.modelValue = { x: 50, y: 50, width: 120, height: 100 }
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 120), 'left')
resizeMove(makePointerEvent('pointermove', captureEl, -100, 120))
resizeEnd(makePointerEvent('pointerup', captureEl, -100, 120))
expect(vm.modelValue.x).toBe(0)
expect(vm.modelValue.width).toBeGreaterThan(120)
})
it('ignores resize move and end before resizing starts', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 40, y: 40, width: 120, height: 120 }
const releaseEl = document.createElement('div')
releaseEl.releasePointerCapture = vi.fn()
;(vm.handleResizeMove as (e: PointerEvent) => void)(
makePointerEvent('pointermove', releaseEl, 360, 360)
)
;(vm.handleResizeEnd as (e: PointerEvent) => void)(
makePointerEvent('pointerup', releaseEl, 360, 360)
)
expect(vm.modelValue).toEqual({ x: 40, y: 40, width: 120, height: 120 })
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
})
it('does not start resizing when there is no image', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const vm = await mountHarness()
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
;(vm.handleResizeStart as (e: PointerEvent, dir: string) => void)(
makePointerEvent('pointerdown', captureEl, 20, 20),
'right'
)
expect(captureEl.setPointerCapture).not.toHaveBeenCalled()
})
it('applies a preset aspect ratio and clamps height to the image', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 800, 500)
@@ -524,6 +719,25 @@ describe('useImageCrop', () => {
expect(vm.isLockEnabled).toBe(false)
})
it('ignores unknown aspect-ratio presets and unlocks explicit lock changes', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 0, y: 0, width: 160, height: 120 }
vm.selectedRatio = 'missing'
expect(vm.selectedRatio).toBe('custom')
expect(vm.isLockEnabled).toBe(false)
vm.isLockEnabled = true
expect(vm.selectedRatio).toBe('4:3')
vm.isLockEnabled = true
expect(vm.selectedRatio).toBe('4:3')
vm.isLockEnabled = false
expect(vm.selectedRatio).toBe('custom')
})
it('shows custom in the ratio label when lock does not match a preset', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
@@ -583,6 +797,58 @@ describe('useImageCrop', () => {
expect(vm.modelValue.y + vm.modelValue.height).toBeLessThanOrEqual(400)
})
it('clamps constrained north-west resize to the image top-left bounds', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 20, y: 20, width: 80, height: 80 }
vm.isLockEnabled = true
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 40, 40), 'nw')
resizeMove(makePointerEvent('pointermove', captureEl, -200, -200))
resizeEnd(makePointerEvent('pointerup', captureEl, -200, -200))
expect(vm.modelValue.x).toBeGreaterThanOrEqual(0)
expect(vm.modelValue.y).toBeGreaterThanOrEqual(0)
expect(vm.modelValue.width).toBeGreaterThanOrEqual(16)
expect(vm.modelValue.height).toBeGreaterThanOrEqual(16)
})
it('clamps constrained corner resize to minimum dimensions', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)
vm.modelValue = { x: 40, y: 40, width: 160, height: 80 }
vm.isLockEnabled = true
const captureEl = document.createElement('div')
captureEl.setPointerCapture = vi.fn()
captureEl.releasePointerCapture = vi.fn()
const resizeStart = vm.handleResizeStart as (
e: PointerEvent,
dir: string
) => void
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
resizeStart(makePointerEvent('pointerdown', captureEl, 200, 120), 'se')
resizeMove(makePointerEvent('pointermove', captureEl, -800, -800))
resizeEnd(makePointerEvent('pointerup', captureEl, -800, -800))
expect(vm.modelValue.width).toBe(32)
expect(vm.modelValue.height).toBe(16)
})
it('ends resize and clears direction on pointerup', async () => {
const vm = await mountHarness()
setupImageLayout(vm, 400, 400)

View File

@@ -0,0 +1,134 @@
import { createApp, h, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import type { Ref } from 'vue'
type ObserverInit = ConstructorParameters<typeof IntersectionObserver>[1]
type ObserverCallback = ConstructorParameters<typeof IntersectionObserver>[0]
const observers: MockIntersectionObserver[] = []
class MockIntersectionObserver {
readonly callback: ObserverCallback
readonly options?: ObserverInit
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(callback: ObserverCallback, options?: ObserverInit) {
this.callback = callback
this.options = options
observers.push(this)
}
}
function mountObserver(
target: Ref<Element | null>,
callback: IntersectionObserverCallback,
options: Parameters<typeof useIntersectionObserver>[2] = {}
) {
let result: ReturnType<typeof useIntersectionObserver> | undefined
const app = createApp({
setup() {
result = useIntersectionObserver(target, callback, options)
return () => h('div')
}
})
app.mount(document.createElement('div'))
if (!result) throw new Error('useIntersectionObserver did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
beforeEach(() => {
observers.length = 0
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver
})
})
describe('useIntersectionObserver', () => {
it('observes the target immediately and updates intersection state', async () => {
const target = ref<Element | null>(document.createElement('div'))
const callback = vi.fn()
const { result, unmount } = mountObserver(target, callback, {
threshold: 0.5
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(result.isSupported).toBe(true)
expect(observers).toHaveLength(1)
expect(observers[0].options).toMatchObject({ threshold: 0.5 })
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
observers[0].callback(
[
{ isIntersecting: false },
{ isIntersecting: true }
] as IntersectionObserverEntry[],
observers[0] as unknown as IntersectionObserver
)
expect(result.isIntersecting.value).toBe(true)
expect(callback).toHaveBeenCalled()
unmount()
expect(observers[0].disconnect).toHaveBeenCalled()
})
it('supports manual observe, unobserve, and cleanup', () => {
const target = ref<Element | null>(document.createElement('div'))
const { result, unmount } = mountObserver(target, vi.fn(), {
immediate: false
})
expect(observers).toHaveLength(0)
result.observe()
expect(observers).toHaveLength(1)
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
result.unobserve()
expect(observers[0].unobserve).toHaveBeenCalledWith(target.value)
result.cleanup()
expect(observers[0].disconnect).toHaveBeenCalled()
unmount()
})
it('does nothing when unsupported or missing a target', () => {
Reflect.deleteProperty(window, 'IntersectionObserver')
const unsupported = mountObserver(
ref(document.createElement('div')),
vi.fn(),
{
immediate: false
}
)
unsupported.result.observe()
expect(unsupported.result.isSupported).toBe(false)
expect(observers).toHaveLength(0)
unsupported.unmount()
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: MockIntersectionObserver
})
const missingTarget = mountObserver(ref<Element | null>(null), vi.fn(), {
immediate: false
})
missingTarget.result.observe()
expect(observers).toHaveLength(0)
missingTarget.unmount()
})
})

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
@@ -14,6 +15,7 @@ import {
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -21,6 +23,7 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
createMockCanvasPointerEvent,
createMockLGraphNode
@@ -295,6 +298,19 @@ describe('useLoad3d', () => {
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
})
it('keeps the renderer visible when collapsed flag is unset', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
delete mockNode.flags.collapsed
mockLoad3d.renderer!.domElement.hidden = true
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
expect(mockLoad3d.renderer!.domElement.hidden).toBe(false)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
mockNode.widgets!.push({
name: 'model_file',
@@ -331,6 +347,31 @@ describe('useLoad3d', () => {
})
})
it('uses saved config fallbacks for empty render mode and missing light intensity', async () => {
mockNode.properties!['Scene Config'] = {
showGrid: true,
backgroundColor: '#111111',
backgroundImage: '',
backgroundRenderMode: ''
}
mockNode.properties!['Light Config'] = {
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('tiled')
expect(composable.lightConfig.value.intensity).toBe(5)
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
})
it('should set preview mode when no width/height widgets', async () => {
mockNode.widgets = []
@@ -397,6 +438,127 @@ describe('useLoad3d', () => {
expect.objectContaining({ getZoomScale: expect.any(Function) })
)
})
it('passes live dimension, zoom, and context-menu callbacks to Load3d', async () => {
const originalCanvas = app.canvas
const menuOptions = [{ content: 'Inspect' }]
const getNodeMenuOptions = vi.fn(() => menuOptions)
app.canvas = {
ds: { scale: 2.25 },
getNodeMenuOptions
} as unknown as typeof app.canvas
const contextMenuSpy = vi
.spyOn(LiteGraph, 'ContextMenu')
.mockImplementation(function () {})
try {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
expect(config.getDimensions?.()).toEqual({ width: 512, height: 512 })
mockNode.widgets![0].value = 640
mockNode.widgets![1].value = 360
expect(config.getDimensions?.()).toEqual({ width: 640, height: 360 })
expect(config.getZoomScale!()).toBe(2.25)
const event = new MouseEvent('contextmenu')
config.onContextMenu!(event)
expect(getNodeMenuOptions).toHaveBeenCalledWith(mockNode)
expect(contextMenuSpy).toHaveBeenCalledWith(menuOptions, {
event,
title: mockNode.type,
extra: mockNode
})
} finally {
app.canvas = originalCanvas
contextMenuSpy.mockRestore()
}
})
it('falls back to zoom scale 1 when the app canvas is unavailable', async () => {
const originalCanvas = app.canvas
app.canvas = undefined as unknown as typeof app.canvas
try {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
expect(config.getZoomScale!()).toBe(1)
} finally {
app.canvas = originalCanvas
}
})
it('restores and enables a saved HDRI when it loads successfully', async () => {
mockNode.properties!['Light Config'] = {
intensity: 7,
hdri: {
enabled: true,
hdriPath: '3d/env.hdr',
showAsBackground: true,
intensity: 2
}
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
'http://localhost/view?filename=env.hdr'
)
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
})
it('clears saved HDRI state when restore fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockNode.properties!['Light Config'] = {
intensity: 7,
hdri: {
enabled: true,
hdriPath: '3d/missing.hdr',
showAsBackground: true,
intensity: 2
}
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'3d',
'missing.hdr'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=missing.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=missing.hdr'
)
vi.mocked(mockLoad3d.loadHDRI!).mockRejectedValueOnce(
new Error('missing')
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(warnSpy).toHaveBeenCalledWith(
'Failed to restore HDRI:',
expect.any(Error)
)
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
warnSpy.mockRestore()
})
})
describe('zoom watcher', () => {
@@ -526,6 +688,31 @@ describe('useLoad3d', () => {
})
describe('configuration watchers', () => {
it('updates refs before Load3d exists without calling scene methods', async () => {
const composable = useLoad3d(mockNode)
composable.sceneConfig.value.backgroundColor = '#abcdef'
composable.sceneConfig.value.backgroundImage = 'pending.png'
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
composable.modelConfig.value.upDirection = '+z'
composable.modelConfig.value.materialMode = 'normal'
composable.modelConfig.value.showSkeleton = true
composable.cameraConfig.value.fov = 55
composable.lightConfig.value.intensity = 9
composable.lightConfig.value.hdri!.intensity = 3
composable.lightConfig.value.hdri!.showAsBackground = true
composable.lightConfig.value.hdri!.enabled = true
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
expect(mockLoad3d.setShowSkeleton).not.toHaveBeenCalled()
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
})
it('should update scene config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -567,20 +754,23 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.setUpDirection!).mockClear()
vi.mocked(mockLoad3d.setMaterialMode!).mockClear()
vi.mocked(mockLoad3d.setShowSkeleton!).mockClear()
composable.modelConfig.value.upDirection = '+y'
composable.modelConfig.value.materialMode = 'wireframe'
composable.modelConfig.value.showSkeleton = true
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(mockLoad3d.setShowSkeleton).toHaveBeenCalledWith(true)
const savedModelConfig = mockNode.properties['Model Config'] as Record<
string,
unknown
>
expect(savedModelConfig.upDirection).toBe('+y')
expect(savedModelConfig.materialMode).toBe('wireframe')
expect(savedModelConfig.showSkeleton).toBe(false)
expect(savedModelConfig.showSkeleton).toBe(true)
})
it('should update camera config when values change', async () => {
@@ -906,6 +1096,68 @@ describe('useLoad3d', () => {
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
})
it('applies scalar Load3d events to config refs', async () => {
const handlers: Record<string, (value: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
handlers[event] = handler as (value: unknown) => void
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
handlers.backgroundColorChange('#123456')
handlers.backgroundRenderModeChange('panorama')
handlers.lightIntensityChange(8)
handlers.fovChange(60)
handlers.cameraTypeChange('orthographic')
handlers.showGridChange(false)
handlers.upDirectionChange('-z')
handlers.backgroundImageChange('scene.jpg')
expect(composable.sceneConfig.value).toMatchObject({
backgroundColor: '#123456',
backgroundRenderMode: 'panorama',
showGrid: false,
backgroundImage: 'scene.jpg'
})
expect(composable.lightConfig.value.intensity).toBe(8)
expect(composable.cameraConfig.value).toMatchObject({
fov: 60,
cameraType: 'orthographic'
})
expect(composable.modelConfig.value.upDirection).toBe('-z')
})
it('should handle background image loading events', async () => {
let startHandler: (() => void) | undefined
let endHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'backgroundImageLoadingStart') {
startHandler = handler as () => void
} else if (event === 'backgroundImageLoadingEnd') {
endHandler = handler as () => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
startHandler?.()
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe(
'load3d.loadingBackgroundImage'
)
endHandler?.()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should handle loading events', async () => {
let modelLoadingStartHandler: (() => void) | undefined
let modelLoadingEndHandler: (() => void) | undefined
@@ -934,6 +1186,70 @@ describe('useLoad3d', () => {
expect(composable.loadingMessage.value).toBe('')
})
it('handles stale modelLoadingEnd after cleanup with fallback model state', async () => {
let modelLoadingEndHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler as () => void
}
}
)
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValue(true)
vi.mocked(mockLoad3d.isPlyModel!).mockReturnValue(true)
vi.mocked(mockLoad3d.getSourceFormat!).mockReturnValue('splat')
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
composable.cleanup()
modelLoadingEndHandler?.()
expect(composable.isSplatModel.value).toBe(false)
expect(composable.isPlyModel.value).toBe(false)
expect(composable.sourceFormat.value).toBeNull()
expect(composable.canFitToViewer.value).toBe(true)
expect(composable.canUseGizmo.value).toBe(true)
expect(composable.canUseLighting.value).toBe(true)
expect(composable.canExport.value).toBe(true)
})
it('uses fallback model capabilities when Load3d returns none', async () => {
let modelLoadingEndHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler as () => void
}
}
)
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValue(true)
vi.mocked(mockLoad3d.getSourceFormat!).mockReturnValue('splat')
vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValue(
fromAny(undefined)
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
modelLoadingEndHandler?.()
expect(composable.isSplatModel.value).toBe(true)
expect(composable.sourceFormat.value).toBe('splat')
expect(composable.canCenterCameraOnModel.value).toBe(true)
expect(composable.canFitToViewer.value).toBe(true)
expect(composable.canUseGizmo.value).toBe(true)
expect(composable.canUseLighting.value).toBe(true)
expect(composable.canExport.value).toBe(true)
expect(composable.materialModes.value).toEqual([
'original',
'normal',
'wireframe'
])
})
it('should handle recordingStatusChange event', async () => {
let recordingStatusHandler: ((status: boolean) => void) | undefined
@@ -956,6 +1272,70 @@ describe('useLoad3d', () => {
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
it('should handle recordingStatusChange true without recording duration update', async () => {
let recordingStatusHandler: ((status: boolean) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'recordingStatusChange') {
recordingStatusHandler = handler as (status: boolean) => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
recordingStatusHandler?.(true)
expect(composable.isRecording.value).toBe(true)
expect(mockLoad3d.getRecordingDuration).not.toHaveBeenCalled()
})
it('should use the default export loading message when none is provided', async () => {
let exportLoadingStartHandler: ((message: string) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'exportLoadingStart') {
exportLoadingStartHandler = handler as (message: string) => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
exportLoadingStartHandler?.('')
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('load3d.exportingModel')
})
it('handles export completion and animation list events', async () => {
const handlers: Record<string, (value?: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
handlers[event] = handler as (value?: unknown) => void
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
handlers.exportLoadingStart('Exporting GLB')
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('Exporting GLB')
handlers.exportLoadingEnd()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
const animations = [{ name: 'Idle', index: 0 }]
handlers.animationListChange(animations)
expect(composable.animations.value).toEqual(animations)
})
})
describe('cleanup', () => {
@@ -977,6 +1357,19 @@ describe('useLoad3d', () => {
expect(() => composable.cleanup()).not.toThrow()
})
it('stops cleanup early when the node ref is already cleared', async () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
await composable.initializeLoad3d(document.createElement('div'))
nodeRef.value = null
composable.cleanup()
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
expect(mockLoad3d.remove).not.toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
})
describe('handleModelDrop', () => {
@@ -1008,6 +1401,62 @@ describe('useLoad3d', () => {
)
})
it('updates model_file widget values after a successful model drop', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['existing.glb'] }
} as unknown as IWidget
mockNode.widgets!.push(modelWidget)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(modelWidget.value).toBe('uploaded/model.glb')
expect(modelWidget.options.values).toEqual([
'existing.glb',
'uploaded/model.glb'
])
})
it('uses output resource URLs when dropping a model into preview mode', async () => {
mockNode.widgets = []
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'uploaded',
'model.glb',
'output'
)
})
it('should use resource folder for upload subfolder', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
@@ -1047,6 +1496,69 @@ describe('useLoad3d', () => {
'toastMessages.no3dScene'
)
})
it('returns before upload when the node ref is cleared after initialization', async () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
await composable.initializeLoad3d(document.createElement('div'))
nodeRef.value = null
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('shows an upload failure alert when model upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('')
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.fileUploadFailed'
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('shows a load failure alert when model loading throws', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('bad model')
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleModelDrop(
new File([''], 'model.glb', { type: 'model/gltf-binary' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Model drop failed:',
expect.any(Error)
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
errorSpy.mockRestore()
})
})
describe('hdri controls', () => {
@@ -1092,6 +1604,40 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
})
it('restores non-HDRI light intensity when HDRI is disabled', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value.intensity = 8
await nextTick()
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
composable.lightConfig.value.intensity = 1
await nextTick()
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: false }
}
await nextTick()
expect(composable.lightConfig.value.intensity).toBe(8)
expect(mockLoad3d.setHDRIEnabled).toHaveBeenLastCalledWith(false)
})
it('should ignore HDRI updates before Load3d is initialized', async () => {
const composable = useLoad3d(mockNode)
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('should upload file, load HDRI and update hdriConfig', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
@@ -1117,6 +1663,100 @@ describe('useLoad3d', () => {
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
})
it('skips HDRI loading when the Load3d instance changes after upload', async () => {
const composable = useLoad3d(mockNode)
vi.mocked(Load3dUtils.uploadFile).mockImplementation(async () => {
composable.cleanup()
return '3d/env.hdr'
})
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(mockLoad3d.loadHDRI).not.toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
})
it('skips HDRI state updates when the Load3d instance changes after load', async () => {
const composable = useLoad3d(mockNode)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
vi.mocked(mockLoad3d.loadHDRI!).mockImplementation(async () => {
composable.cleanup()
})
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(mockLoad3d.loadHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should leave HDRI state unchanged when upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('')
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'env.hdr', { type: 'image/x-hdr' })
)
expect(mockLoad3d.loadHDRI).not.toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should clear HDRI and show an alert when HDRI loading fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/bad.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'bad.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=bad.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=bad.hdr'
)
vi.mocked(mockLoad3d.loadHDRI!).mockRejectedValueOnce(
new Error('bad hdri')
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
await composable.handleHDRIFileUpdate(
new File([''], 'bad.hdr', { type: 'image/x-hdr' })
)
expect(errorSpy).toHaveBeenCalledWith(
'Failed to load HDRI:',
expect.any(Error)
)
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadHDRI'
)
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
errorSpy.mockRestore()
})
it('should clear HDRI when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -1180,6 +1820,23 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
it('skips background color updates while HDRI is enabled', async () => {
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
vi.mocked(mockLoad3d.setBackgroundColor!).mockClear()
composable.sceneConfig.value.backgroundColor = '#ffffff'
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
})
})
describe('gizmo controls', () => {
@@ -1219,6 +1876,37 @@ describe('useLoad3d', () => {
})
})
it('applies restored gizmo config after model loading ends', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
}
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
vi.mocked(mockLoad3d.applyGizmoTransform!).mockClear()
vi.mocked(mockLoad3d.setGizmoEnabled!).mockClear()
vi.mocked(mockLoad3d.setGizmoMode!).mockClear()
const loadingEndCall = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.find(([event]) => event === 'modelLoadingEnd')
const loadingEndHandler = loadingEndCall![1] as () => void
loadingEndHandler()
expect(mockLoad3d.applyGizmoTransform).toHaveBeenCalledWith(
{ x: 1, y: 2, z: 3 },
{ x: 0.1, y: 0.2, z: 0.3 },
{ x: 2, y: 2, z: 2 }
)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
})
it('should add default gizmo config when missing from saved config', async () => {
mockNode.properties!['Model Config'] = {
upDirection: 'original',
@@ -1581,6 +2269,29 @@ describe('useLoad3d', () => {
)
})
it('captures thumbnail from an image widget when no model_file widget exists', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'preview.png'
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
mockNode.widgets = [
{ name: 'image', value: 'preview.png' } as unknown as IWidget
]
const { handler } = await getModelReadyHandler()
handler()
await new Promise((r) => setTimeout(r, 0))
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
expect(persistThumbnail).toHaveBeenCalledWith(
'preview.png',
expect.any(Blob)
)
})
it('skips persistence when the model widget has no value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
@@ -1839,6 +2550,29 @@ describe('useLoad3d', () => {
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('cameraChanged creates camera properties when the node has none', async () => {
let cameraChangedHandler: ((state: unknown) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'cameraChanged') {
cameraChangedHandler = handler as (state: unknown) => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
mockNode.properties = undefined as unknown as LGraphNode['properties']
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'perspective',
fov: 75,
state: { position: { x: 1, y: 2, z: 3 } }
})
})
it('handleStopRecording marks dirty when a recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -1909,6 +2643,27 @@ describe('useLoad3d', () => {
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleCenterCameraOnModel is a no-op before initialization', () => {
const composable = useLoad3d(mockNode)
composable.handleCenterCameraOnModel()
expect(mockLoad3d.centerCameraOnModel).not.toHaveBeenCalled()
})
it('handleSeek is a no-op before animation duration is known', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleSeek(50)
expect(mockLoad3d.setAnimationTime).not.toHaveBeenCalled()
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
})
it('handleSeek marks dirty when the animation has a duration', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

View File

@@ -1,3 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -128,6 +129,9 @@ describe('useLoad3dViewer', () => {
setCameraState: vi.fn(),
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
toggleAnimation: vi.fn(),
setAnimationSpeed: vi.fn(),
updateSelectedAnimation: vi.fn(),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
@@ -147,7 +151,12 @@ describe('useLoad3dViewer', () => {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
}),
setAnimationTime: vi.fn(),
getAnimationDuration: vi.fn().mockReturnValue(12),
animationManager: {
animationClips: []
} as Partial<Load3d['animationManager']> as Load3d['animationManager']
}
mockSourceLoad3d = {
@@ -169,6 +178,18 @@ describe('useLoad3dViewer', () => {
currentUpDirection: 'original',
materialMode: 'original'
} as Load3d['modelManager'],
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
forceRender: vi.fn()
@@ -248,6 +269,91 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('passes target dimensions when width and height widgets are present', async () => {
mockNode.widgets = [
{ name: 'width', value: 640 },
{ name: 'height', value: 360 }
] as LGraphNode['widgets']
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({
width: 640,
height: 360,
isViewerMode: true,
getDimensions: expect.any(Function)
})
)
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
mockNode.widgets![0].value = 800
mockNode.widgets![1].value = 450
expect(config.getDimensions?.()).toEqual({ width: 800, height: 450 })
})
it('falls back to source values when saved configs are empty', async () => {
mockNode.properties!['Scene Config'] = {
backgroundColor: '',
showGrid: undefined,
backgroundImage: '',
backgroundRenderMode: ''
}
mockNode.properties!['Camera Config'] = {
cameraType: undefined,
fov: undefined
}
mockNode.properties!['Model Config'] = {
upDirection: undefined,
materialMode: undefined
}
mockSourceLoad3d.sceneManager!.backgroundRenderMode = 'panorama'
mockSourceLoad3d.sceneManager!.gridHelper.visible = false
mockSourceLoad3d.sceneManager!.currentBackgroundColor = '#111111'
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
'orthographic'
)
mockSourceLoad3d.cameraManager!.perspectiveCamera.fov = 35
mockSourceLoad3d.modelManager!.currentUpDirection = '+y'
mockSourceLoad3d.modelManager!.materialMode = 'normal'
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(viewer.backgroundColor.value).toBe('#111111')
expect(viewer.showGrid.value).toBe(false)
expect(viewer.backgroundRenderMode.value).toBe('panorama')
expect(viewer.cameraType.value).toBe('orthographic')
expect(viewer.fov.value).toBe(35)
expect(viewer.upDirection.value).toBe('+y')
expect(viewer.materialMode.value).toBe('normal')
})
it('initializes animation state from existing clips', async () => {
vi.mocked(mockLoad3d.hasAnimations!).mockReturnValue(true)
mockLoad3d.animationManager = {
animationClips: [{ name: 'Walk' }, { name: '' }]
} as Partial<Load3d['animationManager']> as Load3d['animationManager']
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(viewer.animations.value).toEqual([
{ name: 'Walk', index: 0 },
{ name: 'Animation 2', index: 1 }
])
expect(viewer.animationDuration.value).toBe(12)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -265,6 +371,41 @@ describe('useLoad3dViewer', () => {
})
describe('error handling', () => {
it('ignores watcher changes before Load3d is initialized', async () => {
const viewer = useLoad3dViewer(mockNode)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 60
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'bg.jpg'
viewer.backgroundRenderMode.value = 'panorama'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'normal'
viewer.playing.value = true
viewer.selectedSpeed.value = 2
viewer.selectedAnimation.value = 1
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await nextTick()
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
expect(mockLoad3d.toggleGrid).not.toHaveBeenCalled()
expect(mockLoad3d.toggleCamera).not.toHaveBeenCalled()
expect(mockLoad3d.setFOV).not.toHaveBeenCalled()
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
expect(mockLoad3d.toggleAnimation).not.toHaveBeenCalled()
expect(mockLoad3d.setAnimationSpeed).not.toHaveBeenCalled()
expect(mockLoad3d.updateSelectedAnimation).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
})
it('should handle watcher errors gracefully', async () => {
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
function () {
@@ -284,6 +425,82 @@ describe('useLoad3dViewer', () => {
'toastMessages.failedToUpdateBackgroundColor'
)
})
it('surfaces watcher errors for each viewer control', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(mockLoad3d.toggleGrid!).mockImplementation(() => {
throw new Error('grid failed')
})
vi.mocked(mockLoad3d.toggleCamera!).mockImplementation(() => {
throw new Error('camera failed')
})
vi.mocked(mockLoad3d.setFOV!).mockImplementation(() => {
throw new Error('fov failed')
})
vi.mocked(mockLoad3d.setLightIntensity!).mockImplementation(() => {
throw new Error('light failed')
})
vi.mocked(mockLoad3d.setBackgroundImage!).mockRejectedValue(
new Error('background failed')
)
vi.mocked(mockLoad3d.setBackgroundRenderMode!).mockImplementation(() => {
throw new Error('render failed')
})
vi.mocked(mockLoad3d.setUpDirection!).mockImplementation(() => {
throw new Error('up failed')
})
vi.mocked(mockLoad3d.setMaterialMode!).mockImplementation(() => {
throw new Error('material failed')
})
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
viewer.showGrid.value = false
await nextTick()
viewer.showGrid.value = true
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 60
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'bg.jpg'
viewer.backgroundRenderMode.value = 'panorama'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'normal'
await nextTick()
await Promise.resolve()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToToggleGrid'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToToggleCamera'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateFOV'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateLightIntensity'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundImage'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundRenderMode'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateUpDirection'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateMaterialMode'
)
consoleErrorSpy.mockRestore()
})
})
describe('exportModel', () => {
@@ -368,6 +585,42 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('handles animation controls and seek after progress is known', async () => {
const eventHandlers: Record<string, (value: unknown) => void> = {}
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
eventHandlers[event] = handler as (value: unknown) => void
}
)
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
eventHandlers.animationListChange([{ name: 'Spin', index: 0 }])
eventHandlers.animationProgressChange({
progress: 25,
currentTime: 1,
duration: 8
})
viewer.playing.value = true
viewer.selectedSpeed.value = 0
viewer.selectedAnimation.value = undefined as unknown as number
await nextTick()
viewer.selectedSpeed.value = 2
viewer.selectedAnimation.value = 0
await nextTick()
viewer.handleSeek(50)
expect(viewer.animations.value).toEqual([{ name: 'Spin', index: 0 }])
expect(viewer.animationProgress.value).toBe(25)
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(0)
expect(mockLoad3d.setAnimationTime).toHaveBeenCalledWith(4)
})
})
describe('restoreInitialState', () => {
@@ -422,6 +675,14 @@ describe('useLoad3dViewer', () => {
.futureField
).toBe('preserve-me')
})
it('does nothing in standalone mode', () => {
const viewer = useLoad3dViewer()
viewer.restoreInitialState()
expect(viewer.needApplyChanges.value).toBe(true)
})
})
describe('applyChanges', () => {
@@ -476,6 +737,24 @@ describe('useLoad3dViewer', () => {
expect(result).toBe(false)
})
it('applies without writing properties or dirtying when node state is absent', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
mockNode.properties = undefined as unknown as LGraphNode['properties']
mockNode.graph = undefined as unknown as LGraphNode['graph']
const result = await viewer.applyChanges()
expect(result).toBe(true)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenLastCalledWith(
mockLoad3d,
mockSourceLoad3d
)
})
it('should preserve unknown fields on Model Config when applying', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -578,6 +857,37 @@ describe('useLoad3dViewer', () => {
)
})
it('alerts when uploading without an active Load3d instance', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.handleBackgroundImageUpdate(
new File([''], 'test.jpg', { type: 'image/jpeg' })
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
)
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
})
it('leaves existing background state when upload returns no path', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
viewer.backgroundImage.value = 'existing.jpg'
viewer.hasBackgroundImage.value = true
await viewer.handleBackgroundImageUpdate(
new File([''], 'test.jpg', { type: 'image/jpeg' })
)
expect(viewer.backgroundImage.value).toBe('existing.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should work in standalone mode without a node', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
@@ -664,6 +974,69 @@ describe('useLoad3dViewer', () => {
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
})
it('updates the model_file widget after a successful drop', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['existing.glb'] }
}
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'new.glb'))
expect(modelWidget.value).toBe('3d/new.glb')
expect(modelWidget.options.values).toEqual(['existing.glb', '3d/new.glb'])
})
it('does not duplicate model_file widget options', async () => {
const modelWidget = {
name: 'model_file',
value: '',
options: { values: ['3d/new.glb'] }
}
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'new.glb'))
expect(modelWidget.options.values).toEqual(['3d/new.glb'])
})
it('alerts when model loading throws', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/bad.glb')
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('bad model')
)
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
await viewer.handleModelDrop(new File([''], 'bad.glb'))
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
consoleErrorSpy.mockRestore()
})
})
describe('cleanup', () => {
@@ -694,6 +1067,17 @@ describe('useLoad3dViewer', () => {
expect(Load3d).not.toHaveBeenCalled()
})
it('should handle missing node in node-mode initialization', async () => {
const viewer = useLoad3dViewer()
await viewer.initializeViewer(
document.createElement('div'),
mockSourceLoad3d as Load3d
)
expect(createLoad3d).not.toHaveBeenCalled()
})
it('should handle orthographic camera', async () => {
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
'orthographic'
@@ -725,6 +1109,95 @@ describe('useLoad3dViewer', () => {
})
describe('standalone mode persistence', () => {
it('should handle missing standalone container ref', async () => {
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(null!, 'model.glb')
expect(createLoad3d).not.toHaveBeenCalled()
})
it('syncs pending hover state during standalone initialization', async () => {
const viewer = useLoad3dViewer()
viewer.handleMouseEnter()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'hover.glb'
)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('shows an alert when first standalone load fails', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('load failed')
)
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'broken.glb'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToLoadModel'
)
consoleErrorSpy.mockRestore()
})
it('shows an alert when reusing a standalone viewer fails to load the next model', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const viewer = useLoad3dViewer()
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'first.glb'
)
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
new Error('reload failed')
)
await viewer.initializeStandaloneViewer(
document.createElement('div'),
'second.glb'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'Failed to load 3D model'
)
consoleErrorSpy.mockRestore()
})
it('restores cached standalone camera state', async () => {
const cameraState = {
position: { x: 2, y: 3, z: 4 },
target: { x: 0, y: 0, z: 0 },
zoom: 2,
cameraType: 'perspective'
}
vi.mocked(mockLoad3d.getCameraState!).mockReturnValue(
fromAny(cameraState)
)
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
await viewer.initializeStandaloneViewer(containerRef, 'camera.glb')
viewer.cleanup()
const restoredViewer = useLoad3dViewer()
await restoredViewer.initializeStandaloneViewer(
containerRef,
'camera.glb'
)
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith(cameraState)
})
it('should save and restore configuration in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')

View File

@@ -381,4 +381,57 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
// Should have second node's content, not first
expect(helpContent.value).toBe('# Second node content')
})
it('returns empty state when no node is selected', async () => {
const nodeRef = ref<ComfyNodeDefImpl | null>(null)
const { baseUrl, helpContent, isLoading, error } =
useNodeHelpContent(nodeRef)
await nextTick()
expect(baseUrl.value).toBe('')
expect(helpContent.value).toBe('')
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockFetch).not.toHaveBeenCalled()
})
it('uses stringified non-error rejections with the node description', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockRejectedValueOnce('offline')
const { error, helpContent } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(error.value).toBe('offline')
expect(helpContent.value).toBe(mockCoreNode.description)
})
it('ignores stale rejected requests after the node changes', async () => {
const nodeRef = ref(mockCoreNode)
let rejectFirst: (reason?: unknown) => void
const firstRequest = new Promise((_resolve, reject) => {
rejectFirst = reject
})
mockFetch
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce({
ok: true,
text: async () => '# Current node content'
})
const { error, helpContent } = useNodeHelpContent(nodeRef)
await nextTick()
nodeRef.value = mockCustomNode
await nextTick()
await flushPromises()
rejectFirst!(new Error('stale failure'))
await flushPromises()
expect(error.value).toBeNull()
expect(helpContent.value).toBe('# Current node content')
})
})

View File

@@ -23,6 +23,7 @@ import {
pasteVideoNodes,
usePaste
} from './usePaste'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
function createMockNode(): LGraphNode {
return createMockLGraphNode({
@@ -71,7 +72,7 @@ const mockCanvas = {
} as Partial<LGraphCanvas> as LGraphCanvas
const mockCanvasStore = {
canvas: mockCanvas,
canvas: mockCanvas as LGraphCanvas | null,
getCanvas: vi.fn(() => mockCanvas)
}
@@ -139,6 +140,17 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when image node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteImageNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
@@ -216,6 +228,14 @@ describe('pasteImageNodes', () => {
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
})
it('omits files whose image node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteImageNodes(mockCanvas, [createImageFile()])
expect(result).toEqual([])
})
})
describe('pasteAudioNode', () => {
@@ -236,6 +256,17 @@ describe('pasteAudioNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when audio node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createAudioFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteAudioNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing audio node when provided', async () => {
const mockNode = createMockNode()
const file = createAudioFile()
@@ -312,6 +343,14 @@ describe('pasteAudioNodes', () => {
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
})
it('omits files whose audio node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteAudioNodes(mockCanvas, [createAudioFile()])
expect(result).toEqual([])
})
})
describe('pasteVideoNode', () => {
@@ -332,6 +371,17 @@ describe('pasteVideoNode', () => {
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('returns null when video node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const file = createVideoFile()
const dataTransfer = createDataTransfer([file])
await expect(
pasteVideoNode(mockCanvas, dataTransfer.items)
).resolves.toBeNull()
})
it('should use existing video node when provided', async () => {
const mockNode = createMockNode()
const file = createVideoFile()
@@ -408,13 +458,23 @@ describe('pasteVideoNodes', () => {
expect(createNode).toHaveBeenCalledTimes(1)
expect(result).toEqual([mockNode])
})
it('omits files whose video node creation fails', async () => {
vi.mocked(createNode).mockResolvedValue(null as never)
const result = await pasteVideoNodes(mockCanvas, [createVideoFile()])
expect(result).toEqual([])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvasStore.canvas = mockCanvas
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(false)
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
@@ -544,6 +604,31 @@ describe('usePaste', () => {
expect(createNode).not.toHaveBeenCalled()
})
it('ignores paste when the target owns native copy paste', () => {
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(true)
usePaste()
const dataTransfer = createDataTransfer([createImageFile()])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(createNode).not.toHaveBeenCalled()
})
it('ignores paste when the canvas is unavailable', () => {
mockCanvasStore.canvas = null
usePaste()
const dataTransfer = createDataTransfer([createImageFile()])
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
expect(createNode).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
})
it('should use existing image node when selected', () => {
const mockNode = createMockLGraphNode({
is_selected: true,
@@ -596,6 +681,66 @@ describe('usePaste', () => {
})
})
it('falls back to litegraph paste when metadata cannot be decoded', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
usePaste()
const dataTransfer = new DataTransfer()
dataTransfer.setData(
'text/html',
`<div data-metadata="${btoa('{')}"></div>`
)
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
errorSpy.mockRestore()
})
it('leaves plain text paste alone in text inputs', () => {
usePaste()
const input = document.createElement('input')
input.type = 'text'
document.body.append(input)
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'plain text')
input.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true
})
)
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
input.remove()
})
it('leaves plain text paste alone in textareas', () => {
usePaste()
const textarea = document.createElement('textarea')
document.body.append(textarea)
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', 'plain text')
textarea.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true
})
)
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
textarea.remove()
})
it('should skip node metadata paste when a media node is selected', async () => {
const mockNode = createMockLGraphNode({
is_selected: true,

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { useProgressBarBackground } from './useProgressBarBackground'
describe('useProgressBarBackground', () => {
it('identifies finite progress values', () => {
const { hasProgressPercent, hasAnyProgressPercent } =
useProgressBarBackground()
expect(hasProgressPercent(undefined)).toBe(false)
expect(hasProgressPercent(Number.NaN)).toBe(false)
expect(hasProgressPercent(0)).toBe(true)
expect(hasAnyProgressPercent(undefined, Number.POSITIVE_INFINITY)).toBe(
false
)
expect(hasAnyProgressPercent(undefined, 42)).toBe(true)
})
it('clamps progress styles to the valid percent range', () => {
const { progressPercentStyle } = useProgressBarBackground()
expect(progressPercentStyle(undefined)).toBeUndefined()
expect(progressPercentStyle(-10)).toEqual({ width: '0%' })
expect(progressPercentStyle(125)).toEqual({ width: '100%' })
expect(progressPercentStyle(37)).toEqual({ width: '37%' })
})
})

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const mockCanvasStore = reactive({
selectedItems: [] as unknown[]
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
function makeNode(widgets?: unknown[]): LGraphNode {
const node = new LGraphNode('Test')
node.widgets = widgets as LGraphNode['widgets']
return node
}
describe('useRefreshableSelection', () => {
beforeEach(() => {
mockCanvasStore.selectedItems = []
})
it('does nothing when no selected widget is refreshable', async () => {
const selection = useRefreshableSelection()
await selection.refreshSelected()
expect(selection.isRefreshable.value).toBe(false)
})
it('refreshes selected widgets that expose a refresh function', async () => {
const refresh = vi.fn()
const ignoredRefresh = vi.fn()
mockCanvasStore.selectedItems = [
makeNode([{ refresh }, { refresh: 'not callable' }, null]),
{ widgets: [{ refresh: ignoredRefresh }] }
]
const selection = useRefreshableSelection()
await nextTick()
expect(selection.isRefreshable.value).toBe(true)
await selection.refreshSelected()
expect(refresh).toHaveBeenCalledOnce()
expect(ignoredRefresh).not.toHaveBeenCalled()
})
it('treats selected nodes without widgets as not refreshable', async () => {
mockCanvasStore.selectedItems = [makeNode()]
const selection = useRefreshableSelection()
await nextTick()
expect(selection.isRefreshable.value).toBe(false)
})
})

View File

@@ -20,6 +20,13 @@ vi.mock('@vueuse/core', () => ({
}))
describe('useServerLogs', () => {
const listenerFor = <T>(eventType: string) =>
vi
.mocked(useEventListener)
.mock.calls.find(([, type]) => type === eventType)?.[2] as
| ((event: CustomEvent<T>) => void)
| undefined
beforeEach(() => {
vi.clearAllMocks()
})
@@ -119,4 +126,135 @@ describe('useServerLogs', () => {
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])
})
it('only captures logs while the matching task is active', async () => {
const { logs, startListening } = useServerLogs({ ui_id: 'task-1' })
await startListening()
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'cm-task-started',
expect.any(Function)
)
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'cm-task-completed',
expect.any(Function)
)
const onLogs = listenerFor<LogsWsMessage>('logs')
const onTaskStarted = listenerFor<{ ui_id: string }>('cm-task-started')
const onTaskDone = listenerFor<{ ui_id: string }>('cm-task-completed')
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'before start' }]
})
})
)
onTaskStarted?.(
new CustomEvent('cm-task-started', { detail: { ui_id: 'other-task' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'wrong task' }]
})
})
)
onTaskStarted?.(
new CustomEvent('cm-task-started', { detail: { ui_id: 'task-1' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'captured' }]
})
})
)
onTaskDone?.(
new CustomEvent('cm-task-completed', { detail: { ui_id: 'other-task' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'still active' }]
})
})
)
onTaskDone?.(
new CustomEvent('cm-task-completed', { detail: { ui_id: 'task-1' } })
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'after done' }]
})
})
)
expect(logs.value).toEqual(['captured', 'still active'])
})
it('ignores invalid and empty log events', async () => {
const { logs, startListening } = useServerLogs()
await startListening()
const onLogs = listenerFor<LogsWsMessage>('logs')
onLogs?.(
new CustomEvent('not-logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: 'wrong event' }]
})
})
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: []
})
})
)
onLogs?.(
new CustomEvent('logs', {
detail: fromAny<LogsWsMessage, unknown>({
type: 'logs',
entries: [{ m: ' ' }]
})
})
)
expect(logs.value).toEqual([])
})
it('stops every registered listener', async () => {
const stopLogs = vi.fn()
const stopTaskStarted = vi.fn()
const stopTaskDone = vi.fn()
vi.mocked(useEventListener)
.mockReturnValueOnce(stopLogs)
.mockReturnValueOnce(stopTaskStarted)
.mockReturnValueOnce(stopTaskDone)
const { startListening, stopListening } = useServerLogs({ ui_id: 'task-1' })
await startListening()
await stopListening()
expect(stopLogs).toHaveBeenCalledTimes(1)
expect(stopTaskStarted).toHaveBeenCalledTimes(1)
expect(stopTaskDone).toHaveBeenCalledTimes(1)
expect(api.subscribeLogs).toHaveBeenLastCalledWith(false)
})
})

View File

@@ -248,6 +248,157 @@ describe('useTemplateFiltering', () => {
])
})
it('accepts plain template arrays and treats invalid ref values as empty', () => {
const plain = useTemplateFiltering([
{
name: 'plain-template',
description: 'Plain template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
expect(
plain.filteredTemplates.value.map((template) => template.name)
).toEqual(['plain-template'])
const invalid = useTemplateFiltering(
ref('not-an-array') as unknown as Parameters<
typeof useTemplateFiltering
>[0]
)
expect(invalid.filteredTemplates.value).toEqual([])
expect(invalid.totalCount.value).toBe(0)
})
it('ignores malformed models and tags while applying active filters', () => {
const templates = ref<TemplateInfo[]>([
{
name: 'model-template',
description: 'Model template',
mediaType: 'image',
mediaSubtype: 'png',
models: ['Flux'],
tags: ['Video']
},
{
name: 'missing-models',
description: 'Missing models',
mediaType: 'image',
mediaSubtype: 'png',
models: 'Flux' as unknown as string[],
tags: 'Video' as unknown as string[]
}
])
const {
availableModels,
availableUseCases,
selectedModels,
selectedUseCases,
filteredTemplates
} = useTemplateFiltering(templates)
expect(availableModels.value).toEqual(['Flux'])
expect(availableUseCases.value).toEqual(['Video'])
selectedModels.value = ['Flux']
selectedUseCases.value = ['Video']
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'model-template'
])
})
it('returns no templates for unknown runs-on filters', () => {
const templates = ref<TemplateInfo[]>([
{
name: 'template',
description: 'Template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { selectedRunsOn, filteredTemplates } =
useTemplateFiltering(templates)
selectedRunsOn.value = ['Unknown target']
expect(filteredTemplates.value).toEqual([])
})
it('supports recommended and popular score sorting', async () => {
defaultRankingStore.computeDefaultScore.mockImplementation(
(_date?: string, rank?: number) => rank ?? 0
)
defaultRankingStore.computePopularScore.mockImplementation(
(_date?: string, usage?: number) => usage ?? 0
)
const templates = ref<TemplateInfo[]>([
{
name: 'low',
description: 'Low score',
mediaType: 'image',
mediaSubtype: 'png',
searchRank: 1,
usage: 10
},
{
name: 'high',
description: 'High score',
mediaType: 'image',
mediaSubtype: 'png',
searchRank: 9,
usage: 1
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'recommended'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'high',
'low'
])
sortBy.value = 'popular'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low',
'high'
])
})
it('keeps equal and missing model sizes stable for size sorting', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'first',
description: 'First',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'second',
description: 'Second',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'model-size-low-to-high'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'first',
'second'
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([

View File

@@ -0,0 +1,102 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import type { TreeNode } from '@/types/treeExplorerTypes'
function node(over: Partial<TreeNode>): TreeNode {
return over as TreeNode
}
// root ─┬─ a ── a1 (leaf)
// └─ b (leaf)
function sampleTree() {
const a1 = node({ key: 'a1', leaf: true })
const a = node({ key: 'a', leaf: false, children: [a1] })
const b = node({ key: 'b', leaf: true })
const root = node({ key: 'root', leaf: false, children: [a, b] })
return { root, a, a1, b }
}
describe('useTreeExpansion', () => {
it('toggleNode adds then removes a node key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
const n = node({ key: 'x' })
toggleNode(n)
expect(expandedKeys.value).toEqual({ x: true })
toggleNode(n)
expect(expandedKeys.value).toEqual({})
})
it('toggleNode ignores nodes without a string key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
toggleNode(node({ key: undefined }))
toggleNode(node({ key: 42 as unknown as string }))
expect(expandedKeys.value).toEqual({})
})
it('expandNode expands the node and all non-leaf descendants only', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
expandNode(root)
// root and a are folders; a1 and b are leaves and must be skipped
expect(expandedKeys.value).toEqual({ root: true, a: true })
})
it('expandNode does nothing for a leaf node', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
expandNode(node({ key: 'leaf', leaf: true }))
expect(expandedKeys.value).toEqual({})
})
it('collapseNode removes the node and its non-leaf descendants', () => {
const expandedKeys = ref<Record<string, boolean>>({
root: true,
a: true,
stray: true
})
const { collapseNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
collapseNode(root)
expect(expandedKeys.value).toEqual({ stray: true })
})
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({})
})
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
// Plain toggle removes only the node's own key, leaving descendants
toggleNodeOnEvent(new MouseEvent('click'), root)
expect(expandedKeys.value).toEqual({ a: true })
})
})

View File

@@ -2484,6 +2484,8 @@
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized",
"loadEventsError": "Failed to load activity. Please try again.",
"loadEventsUnknownError": "Something went wrong while loading activity. Please refresh and try again.",
"eventTypes": {
"creditAdded": "Credits Added",
"accountCreated": "Account Created",

View File

@@ -18,7 +18,7 @@
</div>
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<SubscriptionPanelContentWorkspace v-if="shouldUseWorkspaceBilling" />
<!-- Legacy mode: user-level subscription content -->
<template v-else>
<SubscriptionPanelContentLegacy />
@@ -29,24 +29,20 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isActiveSubscription } = useBillingContext()
</script>

View File

@@ -9,7 +9,7 @@ const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
@@ -35,12 +35,10 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/billing/useBillingRouting', () => ({
useBillingRouting: () => ({
get shouldUseWorkspaceBilling() {
return mockShouldUseWorkspaceBilling
}
})
}))
@@ -88,7 +86,7 @@ describe('useSubscriptionDialog', () => {
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockShouldUseWorkspaceBilling.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -119,7 +117,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -131,7 +129,7 @@ describe('useSubscriptionDialog', () => {
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -146,7 +144,7 @@ describe('useSubscriptionDialog', () => {
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -157,7 +155,7 @@ describe('useSubscriptionDialog', () => {
})
it('opens the team tab when planMode is forced from a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -167,8 +165,9 @@ describe('useSubscriptionDialog', () => {
expect(props.initialPlanMode).toBe('team')
})
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
it('uses the legacy table (with onChooseTeam) on the legacy billing flow', () => {
mockShouldUseWorkspaceBilling.value = false
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
@@ -178,7 +177,7 @@ describe('useSubscriptionDialog', () => {
})
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -196,7 +195,7 @@ describe('useSubscriptionDialog', () => {
})
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = false
const { showPricingTable } = useSubscriptionDialog()
@@ -220,7 +219,7 @@ describe('useSubscriptionDialog', () => {
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
@@ -232,7 +231,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()

View File

@@ -2,7 +2,7 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
@@ -24,7 +24,7 @@ export interface SubscriptionDialogOptions {
}
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
@@ -57,7 +57,7 @@ export const useSubscriptionDialog = () => {
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
if (
flags.teamWorkspacesEnabled &&
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
@@ -95,9 +95,10 @@ export const useSubscriptionDialog = () => {
}
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) when team workspaces are enabled. Replaces the old
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
if (flags.teamWorkspacesEnabled) {
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.
// Resolved lazily (not at composable setup): these three composables form

View File

@@ -208,6 +208,41 @@ describe('scanNodeMediaCandidates', () => {
expect(result).toEqual([])
})
it('returns empty when a media node has no execution id', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', [])],
0,
''
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it('ignores widgets that cannot provide a media filename', () => {
const graph = makeGraph([])
const node = fromAny<LGraphNode, unknown>({
id: 1,
type: 'LoadImage',
widgets: [
{ type: 'number', name: 'image', value: 'photo.png' },
{ type: 'combo', name: 'other', value: 'photo.png' },
{ type: 'combo', name: 'image', value: '' },
{ type: 'combo', name: 'image', value: 42 }
],
mode: 0,
_testExecutionId: '1'
})
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it.for([false, true])(
'returns empty while a media upload is pending on the node (isCloud: %s)',
(isCloud) => {
@@ -377,6 +412,35 @@ describe('scanNodeMediaCandidates', () => {
})
describe('scanAllMediaCandidates', () => {
it('returns empty when no root graph is available', () => {
expect(
scanAllMediaCandidates(fromAny<LGraph, unknown>(null), false)
).toEqual([])
})
it('skips nodes without widgets and subgraph nodes', () => {
const withoutWidgets = fromAny<LGraphNode, unknown>({
id: 1,
type: 'LoadImage',
widgets: undefined,
mode: 0
})
const subgraphNode = makeMediaNode(
2,
'LoadImage',
[makeMediaCombo('image', 'photo.png', [])],
0
)
subgraphNode.isSubgraphNode = fromAny(() => true)
const result = scanAllMediaCandidates(
makeGraph([withoutWidgets, subgraphNode]),
false
)
expect(result).toEqual([])
})
it('skips muted nodes (mode === NEVER)', () => {
const node = makeMediaNode(
1,

View File

@@ -406,6 +406,31 @@ describe('useNodeReplacement', () => {
expect(result).toEqual([])
})
it('skips strings and non-replaceable selected types while matching placeholders', () => {
const placeholder = createPlaceholderNode(1, 'OldType')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(createNewNode())
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
'OldType',
{ type: 'OldType', isReplaceable: false },
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['OldType'])
})
it('should replace multiple different node types at once', () => {
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
const placeholder2 = createPlaceholderNode(
@@ -495,6 +520,117 @@ describe('useNodeReplacement', () => {
expect(graph._nodes[0]).toBe(newNode)
})
it('copies serialized title and search-replace properties', () => {
const placeholder = createPlaceholderNode(7, 'OldType')
placeholder.flags = fromAny(undefined)
placeholder.last_serialization!.title = 'Kept title'
placeholder.last_serialization!.properties = {
'Node name for S&R': 'OldType',
untouched: true
}
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(newNode.title).toBe('Kept title')
expect(newNode.flags).toEqual({})
expect(newNode.properties).toEqual({
'Node name for S&R': 'NewType',
untouched: true
})
})
it('falls back to serialize when replacing nodes without cached serialization', () => {
const placeholder = createPlaceholderNode(
1,
'OldType',
[{ name: 'input', link: null }],
[{ name: 'IMAGE', links: null }]
)
placeholder.last_serialization = fromAny<
LGraphNode['last_serialization'],
unknown
>(undefined)
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[{ name: 'IMAGE', links: null }],
[{ name: 'strength', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(placeholder.serialize).toHaveBeenCalled()
expect(newNode.id).toBe(1)
})
it('keeps prior replacements when a later replacement throws', () => {
const first = createPlaceholderNode(1, 'FirstType')
const second = createPlaceholderNode(2, 'SecondType')
const graph = createMockGraph([first, second])
first.graph = graph
second.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([first, second])
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(createNewNode())
.mockImplementationOnce(() => {
throw new Error('create failed')
})
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('FirstType', {
new_node_id: 'NewFirst',
old_node_id: 'FirstType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('SecondType', {
new_node_id: 'NewSecond',
old_node_id: 'SecondType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['FirstType'])
expect(graph.updateExecutionOrder).toHaveBeenCalled()
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
const placeholder = createPlaceholderNode(
12,
@@ -987,6 +1123,30 @@ describe('useNodeReplacement', () => {
// Only TypeA was replaced; TypeB had no matching placeholder
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(['TypeA'])
})
it('does not remove missing-node errors when no group nodes are replaced', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceAllGroups } = useNodeReplacement()
replaceAllGroups([
{
type: 'TypeA',
nodeTypes: [
makeMissingNodeType('TypeA', {
new_node_id: 'NewA',
old_node_id: 'TypeA',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
}
])
expect(mockRemoveMissingNodesByType).not.toHaveBeenCalled()
})
})
describe('transfer edge cases', () => {
@@ -1022,6 +1182,220 @@ describe('useNodeReplacement', () => {
expect(newNode.inputs[0].link).toBeNull()
})
it('skips input transfer when the new node has no matching input slot', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(1, 'OldType', [
{ name: 'image', link: 10 }
])
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([{ name: 'other', link: null }], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: [{ new_id: 'image', old_id: 'image' }],
output_mapping: null
})
])
expect(link.target_id).toBe(1)
expect(newNode.inputs[0].link).toBeNull()
})
it('skips input transfer when the old link is missing or stale', () => {
const placeholder = createPlaceholderNode(1, 'OldType', [
{ name: 'null_link', link: null },
{ name: 'stale_link', link: 99 }
])
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'null_link', link: null },
{ name: 'stale_link', link: null }
],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: [
{ new_id: 'null_link', old_id: 'null_link' },
{ new_id: 'stale_link', old_id: 'stale_link' }
],
output_mapping: null
})
])
expect(newNode.inputs.map((input) => input.link)).toEqual([null, null])
})
it('skips output transfers with empty or stale old links', () => {
const placeholder = createPlaceholderNode(
1,
'OldType',
[],
[
{ name: 'EMPTY', links: [] },
{ name: 'STALE', links: [99] }
]
)
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[],
[
{ name: 'EMPTY', links: null },
{ name: 'STALE', links: null }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: null,
output_mapping: [
{ old_idx: 0, new_idx: 0 },
{ old_idx: 1, new_idx: 1 }
]
})
])
expect(newNode.outputs[0].links).toBeNull()
expect(newNode.outputs[1].links).toEqual([99])
})
it('skips widget transfer when the serialized value cannot be matched', () => {
const placeholder = createPlaceholderNode(1, 'OldType', [
{ name: 'image', link: null }
])
placeholder.last_serialization!.widgets_values = [undefined]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const callback = vi.fn()
const newNode = createNewNode(
[{ name: 'image', link: null }],
[],
[{ name: 'image', value: 'unchanged' }]
)
Object.assign(newNode.widgets![0], { callback })
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: ['image'],
input_mapping: [{ new_id: 'image', old_id: 'image' }],
output_mapping: null
})
])
expect(newNode.widgets![0].value).toBe('unchanged')
expect(callback).not.toHaveBeenCalled()
})
it('calls widget callbacks when transferring widget and set values', () => {
const placeholder = createPlaceholderNode(1, 'OldType', [
{ name: 'strength', link: null }
])
placeholder.last_serialization!.widgets_values = [0.75]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const strengthCallback = vi.fn()
const modeCallback = vi.fn()
const newNode = createNewNode(
[{ name: 'strength', link: null }],
[],
[
{ name: 'strength', value: 0 },
{ name: 'mode', value: 'old' }
]
)
Object.assign(newNode.widgets![0], { callback: strengthCallback })
Object.assign(newNode.widgets![1], { callback: modeCallback })
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: ['strength'],
input_mapping: [
{ new_id: 'strength', old_id: 'strength' },
{ new_id: 'mode', set_value: 'new' }
],
output_mapping: null
})
])
expect(strengthCallback).toHaveBeenCalledWith(0.75)
expect(modeCallback).toHaveBeenCalledWith('new')
})
it('skips placeholders without a graph or graph index', () => {
const noGraph = createPlaceholderNode(1, 'NoGraph')
noGraph.graph = null
const missingIndex = createPlaceholderNode(2, 'MissingIndex')
const graph = createMockGraph([])
missingIndex.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([noGraph, missingIndex])
vi.mocked(LiteGraph.createNode).mockReturnValue(createNewNode())
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('NoGraph', {
new_node_id: 'NewNoGraph',
old_node_id: 'NoGraph',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('MissingIndex', {
new_node_id: 'NewMissingIndex',
old_node_id: 'MissingIndex',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual([])
expect(LiteGraph.createNode).not.toHaveBeenCalled()
})
it('does not throw when output_mapping references a new output index that does not exist', () => {
// NOTE: The current source skips transfer silently in this case, leaving
// the link's origin_slot pointing at a now-missing slot on the new node.

View File

@@ -1,4 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -55,10 +56,14 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
if (useAuth)
if (useAuth) {
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
)
}
return
}

View File

@@ -59,3 +59,8 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
undefined
)

View File

@@ -111,6 +111,7 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -11,21 +11,49 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const env = vi.hoisted(() => {
const state = {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
return state[key]
}
})
return { state, fakeRef }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
useBillingContext: () => ({
isActiveSubscription: env.fakeRef('isActiveSubscription'),
type: env.fakeRef('billingType')
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
flags: {
get teamWorkspacesEnabled() {
return env.state.teamWorkspacesEnabled
},
get userSecretsEnabled() {
return env.state.userSecretsEnabled
}
}
})
}))
@@ -34,8 +62,12 @@ vi.mock('@/composables/useVueFeatureFlags', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
get isCloud() {
return env.state.isCloud
},
get isDesktop() {
return env.state.isDesktop
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -77,6 +109,16 @@ describe('useSettingUI', () => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
Object.assign(env.state, {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
})
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
@@ -137,4 +179,59 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
describe('legacy billing in the workspace layout', () => {
const navKeys = (groups: { items: { id: string }[] }[]) =>
groups.flatMap((group) => group.items.map((item) => item.id))
beforeEach(() => {
Object.assign(env.state, {
isCloud: true,
isLoggedIn: true,
teamWorkspacesEnabled: true,
isActiveSubscription: true
})
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
})
it('exposes the legacy plan panel when billing is legacy', () => {
env.state.billingType = 'legacy'
const { defaultCategory, navGroups } = useSettingUI('subscription')
expect(defaultCategory.value.key).toBe('subscription')
expect(navKeys(navGroups.value)).toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('hides the legacy plan panel when billing is workspace', () => {
env.state.billingType = 'workspace'
const { navGroups } = useSettingUI()
expect(navKeys(navGroups.value)).not.toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('never renders the plan panel in more than one tab', () => {
const countSubscription = () => {
const { navGroups } = useSettingUI()
return navKeys(navGroups.value).filter((id) => id === 'subscription')
.length
}
for (const teamWorkspacesEnabled of [true, false]) {
for (const billingType of ['legacy', 'workspace'] as const) {
for (const isLoggedIn of [true, false]) {
Object.assign(env.state, {
teamWorkspacesEnabled,
billingType,
isLoggedIn
})
expect(countSubscription()).toBeLessThanOrEqual(1)
}
}
}
})
})
})

View File

@@ -53,7 +53,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription, type: billingType } = useBillingContext()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -157,6 +157,13 @@ export function useSettingUI(
return isActiveSubscription.value
})
const shouldShowLegacyPlanCreditsPanel = computed(
() =>
isLoggedIn.value &&
billingType.value === 'legacy' &&
shouldShowPlanCreditsPanel.value
)
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -301,6 +308,9 @@ export function useSettingUI(
label: 'General',
children: [
translateCategory(userPanel.node),
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [translateCategory(subscriptionPanel.node)]
: []),
...coreSettingCategories.value.slice(0, 1).map(translateCategory),
...(shouldShowSecretsPanel.value
? [translateCategory(secretsPanel.node)]
@@ -332,9 +342,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel.node]
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),

View File

@@ -25,13 +25,15 @@ export enum ServerFeatureFlag {
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
}
export function useFeatureFlags() {
return {
flags: {
teamWorkspacesEnabled: true
teamWorkspacesEnabled: true,
consolidatedBillingEnabled: true
}
}
}