Compare commits

...

17 Commits

Author SHA1 Message Date
Connor Byrne
dd17fcf8c6 fix: use real LGraphNode instance in createMockNode fixture
Instead of using `as unknown as LGraphNode` type assertion, instantiate
a real LGraphNode and assign test-specific overrides. This eliminates
the type escape at the fixture level while maintaining test flexibility.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3186572593

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 13:45:37 -07:00
Connor Byrne
cfc65bda75 Merge remote-tracking branch 'origin/main' into test/cov-litegraphService 2026-05-13 13:17:40 -07:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
pythongosssss
7ce0973386 fix: prevent first user template popup when following shared link (#12024)
## Summary

When a user who has not used the app before first loads up, they are
presented with the template selection dialog. This conflicts when the
first-time user visits the app via a share link - both the share &
template dialog are triggered.

## Changes

- **What**: 
- Skip the templates browser when share param is in URL
- Tests
- Add `url` to `setup`/`goto` to allow specifying the `share` parameter

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12024-fix-prevent-first-user-template-popup-when-following-shared-link-3586d73d365081cbbcecdba45a1ad1ea)
by [Unito](https://www.unito.io)
2026-05-13 19:19:54 +00:00
Yourz
6e9be7b164 feat: add Anthropic partner icon (#12216)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the Anthropic logo to the partner-node icon set so nodes whose
category ends in `Anthropic` (e.g. the Claude node added in
Comfy-Org/ComfyUI#13867 with `category="api node/text/Anthropic"`)
render the correct provider badge in the node library.

## Changes

- `packages/design-system/src/icons/anthropic.svg` — new auto-discovered
partner icon (Anthropic A glyph, sourced from
[lobehub/lobe-icons](https://github.com/lobehub/lobe-icons), uses
`fill="currentColor"` for theme adaptation)
- `src/utils/categoryUtil.ts` — register Anthropic's brand coral
`#D97757` as the badge border color
- `packages/design-system/src/css/style.css` — add `anthropic` to the
dynamic comfy-icon safelist so Tailwind/Iconify emits CSS for
`icon-[comfy--anthropic]` in production builds
- `src/utils/categoryUtil.test.ts` — regression tests for
`getProviderIcon('Anthropic')` and `getProviderBorderStyle('Anthropic')`

## Verification

- `pnpm typecheck` ✓
- `pnpm lint` ✓ (0 errors; 3 pre-existing warnings in unrelated files)
- `pnpm format:check` ✓
- `pnpm test:unit -- src/utils/categoryUtil.test.ts` ✓ (13/13)
- `pnpm build` ✓ — confirmed `comfy--anthropic` class is emitted into
`dist/assets/index-*.css`
- Manual visual check via Playwright against `pnpm dev`: injected `<i
class="icon-[comfy--anthropic]">` elements at badge size (10px) and 48px
alongside the existing OpenAI and BFL icons and confirmed the Anthropic
"A" glyph renders correctly in coral. See screenshot.

End-to-end visual verification of the live badge in the node library
requires Comfy-Org/ComfyUI#13867 to land first (the Claude node is what
produces the `Anthropic` category that triggers the icon lookup).

Related: Comfy-Org/ComfyUI#13867

## Screenshots

![Anthropic icon rendered in coral alongside OpenAI and BFL partner
icons at badge and large
sizes](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/c0076decedd8863eec6253b44e583da6b3eaacc20081d126aaf5267c72c8cc84/pr-images/1778683078329-49e37a7b-86ed-4ef2-988f-5702433f8412.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12216-feat-add-Anthropic-partner-icon-35f6d73d36508133a134fcafaf72f4f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 17:01:49 +00:00
Terry Jia
4b5b184cad FE-566: fix Painter mask submission edge cases on cloud (#12196)
## Summary
Rework the painter always hands the backend a valid asset reference:
- Drop the `hasStrokes` flag and the `isCanvasEmpty` check.
- `serializeValue` falls back to the existing `modelValue` when the
canvas element is transiently unmounted, reuses the cached upload when
not dirty and a value is present, and otherwise uploads the current
canvas (a fully transparent PNG is a valid no-op mask, Painter's Python
`execute()` treats painter_alpha=0 the same as "no mask painted").
- `handleClear` now also clears `modelValue` so a user-initiated clear
doesn't resurrect a stale upload on the next serialize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12196-FE-566-fix-Painter-mask-submission-edge-cases-on-cloud-35e6d73d365081dd8856ddb785952526)
by [Unito](https://www.unito.io)
2026-05-13 10:19:39 -04:00
pythongosssss
129bfd9f1b fix: fix drop location and zindex of dragged in images (#12194)
## Summary

Images dragged into the canvas were placed at the last graph mouse
position, which is not updated during the drag event - meaning nodes
were created in "random" locations. Additionally, the z-index was not
set so newly created nodes can appear under other nodes.

## Changes

- **What**: 
- ensure added nodes are at top level
- update graph mouse pos with position from drop event
- tests

## Screenshots (if applicable)


Before / After

https://github.com/user-attachments/assets/34b4652e-a834-4c22-b191-2875a2404ac5

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12194-fix-fix-drop-location-and-zindex-of-dragged-in-images-35e6d73d3650814781edc9f4b4b5b223)
by [Unito](https://www.unito.io)
2026-05-13 10:35:16 +00:00
Christian Byrne
e60ae14bc0 feat(website): hide Free tier behind SHOW_FREE_TIER flag (#12165)
*PR Created by the Glary-Bot Agent*

---

## Summary

Disables the Free tier on the public marketing website
(`comfy.org/cloud/pricing` and `comfy.org/cloud`) behind a single
boolean flag so re-enabling is a one-line change.

The Free tier was already removed from the Comfy Cloud sign-up flow;
this PR removes the matching promotional surfaces on the marketing site
so users hit the paywall directly.

## Changes

- **New** `apps/website/src/config/features.ts` — exports
`SHOW_FREE_TIER` (currently `false`). Flip to `true` to restore the
previous UX.
- **`apps/website/src/components/pricing/PriceSection.vue`** — when
`SHOW_FREE_TIER` is `false`:
  - drops the Free plan card from the pricing array
  - desktop grid collapses from `lg:grid-cols-4` to `lg:grid-cols-3`
- Standard plan's "Everything in Free, plus:" intro is replaced with an
aria-hidden spacer so the three remaining cards stay vertically aligned
- **`apps/website/src/components/product/cloud/PricingSection.vue`** —
hides the "Start free. Upgrade when you're ready." tagline on the
`/cloud` pricing teaser.
- **New** `apps/website/e2e/pricing.spec.ts` — three @smoke tests
asserting the paid tiers + Enterprise are visible and that all Free-tier
surfaces are absent.

All translation strings (`pricing.plan.free.*`, `cloud.pricing.tagline`)
are retained so re-enabling requires no copy work.

## Verification

- `pnpm typecheck` — clean (0 errors, 0 warnings; pre-existing hint
unrelated)
- `pnpm lint` / `oxfmt` — clean
- `pnpm test:unit` — 30/30 passing
- Playwright e2e (desktop project) — `pricing.spec.ts` 3/3 passing,
`cloud.spec.ts` 13/13 still passing
- Visual: desktop and mobile pricing pages render with balanced 3-column
layout; `/cloud` teaser card still proportional.

Screenshot below.

## Screenshots

![/cloud/pricing desktop with Free tier hidden: balanced 3-column layout
(Standard, Creator, Pro) and Enterprise section
below](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/732492b43e4794dae051b2b86643e7a5d3daebce028765f27470561681d4f1db/pr-images/1778561413584-9c1a82d1-781f-49d4-9a26-cc7523c903c4.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12165-feat-website-hide-Free-tier-behind-SHOW_FREE_TIER-flag-35e6d73d36508164b4dfcfe9fee6b5e7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-13 05:40:09 +00:00
Comfy Org PR Bot
b172534f55 [chore] Update Ingest API types from cloud@9619326 (#12202)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 9619326
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-13 03:47:03 +00:00
Connor Byrne
f7f237658c test: cover getCanvasCenter when app.canvas is undefined
Promotes the previously hardcoded app mock to a hoisted, mutable mockApp
object so tests can simulate the runtime case where app.canvas itself is
still uninitialized. Adds a test asserting getCanvasCenter() returns
[0, 0] without throwing in that state, exercising the optional chain on
app.canvas?.ds?.visible_area.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3176135138
2026-05-04 14:06:43 -07:00
Connor Byrne
f88492387b test: type litegraph fixtures and remove call-site as-never casts
Localizes type assertions inside the mock factories so callers receive
properly-typed LGraphNode and IBaseWidget values, eliminating the
'node as never' / 'widget as never' casts at every test call site.
Replaces .call(null as never) on context-menu callbacks with a typed
helper, and drops as-never on goToNode IDs and updatePreviews node
fixtures.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3176135135
2026-05-04 14:05:16 -07:00
Connor Byrne
c09f1d7d15 test: remove change-detector test for Symbol exports
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3176135137
2026-05-04 14:01:07 -07:00
Christian Byrne
4a40c050d9 Merge branch 'main' into test/cov-litegraphService 2026-05-04 13:27:33 -07:00
bymyself
35492bc530 fix: assert error is logged in updatePreviews error test
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3114595641
2026-04-21 18:47:47 -07:00
bymyself
c30177a749 fix: reset mock implementations in beforeEach to prevent leakage
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3114595634
2026-04-21 18:47:47 -07:00
bymyself
cb443d455e fix: use shared nodeOutputStore mock instance via vi.hoisted
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3114595624
2026-04-21 18:47:47 -07:00
bymyself
a378ebb5af test: add unit tests for litegraphService 2026-04-20 18:45:03 -07:00
34 changed files with 1372 additions and 101 deletions

View File

@@ -0,0 +1,56 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Pricing page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/pricing')
})
test('shows the three paid tiers and Enterprise', async ({ page }) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
await expect(
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
).toBeVisible()
}
await expect(
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
).toBeVisible()
})
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
page
}) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
await expect(
pricingGrid.locator('span', { hasText: /^FREE$/ })
).toHaveCount(0)
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
0
)
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
})
})
test.describe('Cloud pricing teaser @smoke', () => {
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
page
}) => {
await page.goto('/cloud')
await expect(
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
).toHaveCount(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -7,6 +7,7 @@ import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
@@ -37,21 +38,23 @@ interface PricingPlan {
isEnterprise?: boolean
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const plans: PricingPlan[] = [
{
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
},
...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
@@ -61,7 +64,9 @@ const plans: PricingPlan[] = [
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
featureIntroKey: 'pricing.plan.standard.featureIntro',
featureIntroKey: SHOW_FREE_TIER
? 'pricing.plan.standard.featureIntro'
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
@@ -150,9 +155,14 @@ const activePlanIndex = ref(0)
</button>
</div>
<!-- Desktop: 4-column grid / Mobile: single card -->
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
@@ -223,10 +233,18 @@ const activePlanIndex = ref(0)
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
}}
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { SHOW_FREE_TIER } from '../../../config/features'
import { getRoutes } from '../../../config/routes'
import { t } from '../../../i18n/translations'
@@ -25,7 +26,10 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('cloud.pricing.description', locale) }}
</p>
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
</div>

View File

@@ -0,0 +1 @@
export const SHOW_FREE_TIER = false

View File

@@ -0,0 +1,90 @@
{
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [230, 110],
"size": [270, 317.5666809082031],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveFloat",
"pos": [-80.55032348632812, 375.2260443115233],
"size": [270, 80.23332977294922],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveFloat"
},
"widgets_values": [0]
}
],
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [1335.8909766107738, 692.7345403667316]
},
"frontendVersion": "1.45.4"
},
"version": 0.4
}

View File

@@ -285,10 +285,12 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true
mockReleases = true,
url
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -320,7 +322,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto()
await this.goto({ url })
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -347,8 +349,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto() {
await this.page.goto(this.url)
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
}
async nextFrame() {

View File

@@ -74,6 +74,90 @@ test.describe(
})
})
test.describe('Image without workflow', () => {
test('places LoadImage at the drop cursor when graph_mouse is stale', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.canvas.graph_mouse[0] = -9999
window.app!.canvas.graph_mouse[1] = -9999
})
const dropPosition = { x: 480, y: 320 }
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
dropPosition,
waitForUpload: true
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const { nodePos, expectedPos } = await comfyPage.page.evaluate(
(drop) => {
const canvas = window.app!.canvas
const expected = canvas.convertEventToCanvasOffset(
new MouseEvent('drop', {
clientX: drop.x,
clientY: drop.y
})
) as [number, number]
const node = window.app!.graph.nodes[0]
return {
nodePos: [node.pos[0], node.pos[1]] as [number, number],
expectedPos: expected
}
},
dropPosition
)
expect(nodePos[0]).toBeCloseTo(expectedPos[0], 0)
expect(nodePos[1]).toBeCloseTo(expectedPos[1], 0)
expect(nodePos[0]).not.toBe(-9999)
expect(nodePos[1]).not.toBe(-9999)
})
test('places LoadImage above existing nodes (zIndex)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const initialNodeIds = await comfyPage.vueNodes.getNodeIds()
expect(initialNodeIds.length).toBeGreaterThan(0)
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
dropPosition: { x: 540, y: 380 },
waitForUpload: true
})
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())
.toBe(initialNodeIds.length + 1)
const newNodeIds = await comfyPage.vueNodes.getNodeIds()
const addedNodeId = newNodeIds.find(
(id) => !initialNodeIds.includes(id)
)
expect(addedNodeId).toBeDefined()
const newNodeZ = await comfyPage.vueNodes
.getNodeLocator(addedNodeId!)
.evaluate((el) => Number((el as HTMLElement).style.zIndex))
const existingZIndexes = await comfyPage.vueNodes.nodes.evaluateAll(
(els, id) =>
els
.filter((el) => el.getAttribute('data-node-id') !== id)
.map((el) => Number((el as HTMLElement).style.zIndex)),
addedNodeId!
)
expect(newNodeZ).toBeGreaterThan(Math.max(0, ...existingZIndexes))
})
})
test('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {

View File

@@ -549,7 +549,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(uploadCount, 'should upload exactly once').toBe(1)
})
test('Empty canvas does not upload on serialization', async ({
test('Empty canvas uploads a transparent placeholder on serialization', async ({
comfyPage
}) => {
let uploadCount = 0
@@ -566,7 +566,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'empty canvas should not upload').toBe(0)
expect(
uploadCount,
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
).toBe(1)
})
test('Upload failure shows error toast', async ({ comfyPage }) => {

View File

@@ -106,6 +106,49 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')

View File

@@ -1133,3 +1133,108 @@ test.describe(
})
}
)
test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
test('should keep widget-input link aligned after persisted-workflow reload', async ({
comfyPage
}) => {
test.setTimeout(30000)
await comfyPage.workflow.loadWorkflow(
'vueNodes/ksampler-denoise-widget-link'
)
await comfyPage.vueNodes.waitForNodes(2)
await comfyPage.workflow.waitForDraftPersisted()
await comfyPage.workflow.reloadAndWaitForApp()
await comfyPage.vueNodes.waitForNodes(2)
const ksampler = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
if (!node) return null
const findIndex = (name: string) =>
node.inputs.findIndex(
(input) => input.name === name || input.widget?.name === name
)
return {
id: node.id,
denoiseIndex: findIndex('denoise'),
schedulerIndex: findIndex('scheduler')
}
})
if (!ksampler) {
throw new Error('KSampler should be present in fixture')
}
expect(
ksampler.denoiseIndex,
'denoise input slot not found'
).toBeGreaterThanOrEqual(0)
expect(
ksampler.schedulerIndex,
'scheduler input slot not found'
).toBeGreaterThanOrEqual(0)
const denoiseSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.denoiseIndex,
true
)
const schedulerSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.schedulerIndex,
true
)
await expectVisibleAll(denoiseSlot, schedulerSlot)
await expect
.poll(() =>
getInputLinkDetails(comfyPage.page, ksampler.id, ksampler.denoiseIndex)
)
.toMatchObject({
targetId: ksampler.id,
targetSlot: ksampler.denoiseIndex
})
// If the regression returns, getInputPos stays stale relative to the
// grown slot DOM and the endpoint drifts toward scheduler. Re-read
// positions each retry so layout settle doesn't cause flakes.
await expect(async () => {
const linkEnd = await comfyPage.page.evaluate(
([nodeId, targetSlotIndex]) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) return null
const slotPos = node.getInputPos(targetSlotIndex)
const [cx, cy] = window.app!.canvas.ds.convertOffsetToCanvas([
slotPos[0],
slotPos[1]
])
const rect = window.app!.canvas.canvas.getBoundingClientRect()
return { x: cx + rect.left, y: cy + rect.top }
},
[ksampler.id, ksampler.denoiseIndex] as const
)
expect(linkEnd, 'link endpoint should resolve').not.toBeNull()
const denoiseCenter = await getCenter(denoiseSlot)
const schedulerCenter = await getCenter(schedulerSlot)
const distToDenoise = Math.hypot(
linkEnd!.x - denoiseCenter.x,
linkEnd!.y - denoiseCenter.y
)
const rowGap = Math.hypot(
denoiseCenter.x - schedulerCenter.x,
denoiseCenter.y - schedulerCenter.y
)
// Bound at rowGap / 4 - half the inter-slot midpoint, so any drift
// toward scheduler fails well before reaching it.
expect(
distToDenoise,
`Link endpoint (${linkEnd!.x.toFixed(1)}, ${linkEnd!.y.toFixed(1)}) is ` +
`${distToDenoise.toFixed(1)}px from denoise — should be within ` +
`${(rowGap / 4).toFixed(1)}px (quarter of inter-slot gap ${rowGap.toFixed(1)}px)`
).toBeLessThan(rowGap / 4)
}).toPass({ timeout: 5000 })
})
})

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -523,6 +523,19 @@ export type ImportPublishedAssetsRequest = {
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array<string>
/**
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
*/
share_id?: string | null
}
/**
@@ -2308,9 +2321,15 @@ export type Asset = {
*/
preview_id?: string | null
/**
* ID of the job/prompt that created this asset, if available
* Deprecated: use job_id instead. ID of the prompt that created this asset, if available
*
* @deprecated
*/
prompt_id?: string | null
/**
* ID of the job that created this asset, if available
*/
job_id?: string | null
/**
* Timestamp when the asset was created
*/
@@ -3150,9 +3169,15 @@ export type AssetWritable = {
*/
preview_id?: string | null
/**
* ID of the job/prompt that created this asset, if available
* Deprecated: use job_id instead. ID of the prompt that created this asset, if available
*
* @deprecated
*/
prompt_id?: string | null
/**
* ID of the job that created this asset, if available
*/
job_id?: string | null
/**
* Timestamp when the asset was created
*/
@@ -4915,10 +4940,6 @@ export type ImportPublishedAssetsErrors = {
* Unauthorized
*/
401: ErrorResponse
/**
* Not found
*/
404: ErrorResponse
/**
* Internal server error
*/

View File

@@ -310,7 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string())
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
})
/**
@@ -1371,6 +1372,7 @@ export const zAsset = z.object({
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
@@ -1812,6 +1814,7 @@ export const zAssetWritable = z.object({
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),

View File

@@ -349,25 +349,75 @@ describe('usePainter', () => {
})
describe('serializeValue', () => {
it('returns empty string when canvas has no strokes', async () => {
it('returns existing modelValue when not dirty (preserves workflow-restored mask reference across WidgetPainter remount)', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
mountPainter('test-node', 'painter/existing.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
expect(result).toBe('painter/existing.png [temp]')
})
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const fetchApiMock = vi.mocked(api.fetchApi)
fetchApiMock.mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'uploaded.png' })
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
expect(result).toBe('painter/uploaded.png [temp]')
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter('test-node', 'painter/cached.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/cached.png [temp]')
})
it('clears the cached upload reference when the user clears the canvas', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fakeCanvas = {
width: 4,
height: 4,
getContext: vi.fn(() => ({
clearRect: vi.fn()
}))
} as unknown as HTMLCanvasElement
const { painter, canvasEl, modelValue } = mountPainter(
'test-node',
'painter/old-upload.png [temp]'
)
canvasEl.value = fakeCanvas
painter.handleClear()
expect(modelValue.value).toBe('')
})
})

View File

@@ -61,7 +61,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
@@ -413,7 +412,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
isDrawing = true
isDirty.value = true
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
@@ -513,7 +511,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
hasStrokes = false
modelValue.value = ''
}
function updateCursorPos(e: PointerEvent) {
@@ -619,17 +617,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
return { filename, subfolder, type }
}
function isCanvasEmpty(): boolean {
return !hasStrokes
}
async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) return ''
if (!el) return modelValue.value
if (isCanvasEmpty()) return ''
if (!isDirty.value) return modelValue.value
if (!isDirty.value && modelValue.value) return modelValue.value
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
@@ -717,7 +709,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''

View File

@@ -76,15 +76,25 @@ vi.mock(
})
)
const commandStoreMocks = vi.hoisted(() => ({
execute: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
execute: commandStoreMocks.execute
})
}))
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, unknown>
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
get query() {
return routeMocks.query
}
}),
useRouter: () => ({
replace: vi.fn()
@@ -97,13 +107,30 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const preservedQueryMocks = vi.hoisted(() => ({
payloads: {} as Record<string, Record<string, string> | undefined>
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(() => null)
mergePreservedQueryIntoQuery: vi.fn(
(namespace: string, query: Record<string, unknown> = {}) => {
const payload = preservedQueryMocks.payloads[namespace]
if (!payload) return undefined
const next: Record<string, unknown> = { ...query }
let changed = false
for (const [key, value] of Object.entries(payload)) {
if (typeof next[key] === 'string') continue
next[key] = value
changed = true
}
return changed ? next : undefined
}
)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template', SHARE: 'share' }
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -178,6 +205,9 @@ describe('useWorkflowPersistenceV2', () => {
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
})
afterEach(() => {
@@ -357,4 +387,43 @@ describe('useWorkflowPersistenceV2', () => {
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('loadDefaultWorkflow', () => {
it('opens templates browser for first-time users', async () => {
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share param is in URL', async () => {
routeMocks.query = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share intent is preserved across /user-select redirect', async () => {
// No-local-user flow: ?share=... was captured into sessionStorage and the
// URL query was dropped during the /user-select redirect before
// initializeWorkflow() runs.
preservedQueryMocks.payloads.share = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
})

View File

@@ -48,6 +48,7 @@ export function useWorkflowPersistenceV2() {
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
@@ -160,11 +161,20 @@ export function useWorkflowPersistenceV2() {
})
}
const hasSharedWorkflowIntent = () => {
if (typeof route.query.share === 'string') return true
hydratePreservedQuery(SHARE_NAMESPACE)
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
return typeof merged?.share === 'string'
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
await useCommandStore().execute('Comfy.BrowseTemplates')
if (!hasSharedWorkflowIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
} else {
await comfyApp.loadGraphData()
}

View File

@@ -90,6 +90,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
@@ -134,4 +135,9 @@ const {
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {
useVueElementTracking(String(nodeData.id), 'widgets-grid')
}
</script>

View File

@@ -29,7 +29,10 @@ const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
function scheduleSlotLayoutSync(nodeId: string) {
export function scheduleSlotLayoutSync(nodeId: string) {
// Drop signals for unregistered nodes (e.g. preview nodes with synthetic
// ids from LGraphNodePreview) - they'd otherwise pump setDirty per RAF.
if (!useNodeSlotRegistryStore().getNode(nodeId)) return
pendingNodes.add(nodeId)
raf.schedule()
}

View File

@@ -43,7 +43,8 @@ const testState = vi.hoisted(() => ({
nodeLayouts: new Map<NodeId, NodeLayout>(),
batchUpdateNodeBounds: vi.fn(),
setSource: vi.fn(),
syncNodeSlotLayoutsFromDOM: vi.fn()
syncNodeSlotLayoutsFromDOM: vi.fn(),
scheduleSlotLayoutSync: vi.fn()
}))
vi.mock('@vueuse/core', () => ({
@@ -73,6 +74,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}))
vi.mock('./useSlotElementTracking', () => ({
scheduleSlotLayoutSync: testState.scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
}))
@@ -159,6 +161,7 @@ describe('useVueNodeResizeTracking', () => {
testState.batchUpdateNodeBounds.mockReset()
testState.setSource.mockReset()
testState.syncNodeSlotLayoutsFromDOM.mockReset()
testState.scheduleSlotLayoutSync.mockReset()
resizeObserverState.observe.mockReset()
resizeObserverState.unobserve.mockReset()
resizeObserverState.disconnect.mockReset()
@@ -317,4 +320,25 @@ describe('useVueNodeResizeTracking', () => {
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
})
it('widgets-grid resize schedules a slot resync without writing node bounds', () => {
const parentNodeId: NodeId = 'parent-node'
const element = document.createElement('div')
element.dataset.widgetsGridNodeId = parentNodeId
const boxSizes = [{ inlineSize: 200, blockSize: 80 }]
const entry = {
target: element,
borderBoxSize: boxSizes,
contentBoxSize: boxSizes,
devicePixelContentBoxSize: boxSizes,
contentRect: new DOMRect(0, 0, 200, 80)
} satisfies ResizeEntryLike
resizeObserverState.callback?.([entry], createObserverMock())
expect(testState.scheduleSlotLayoutSync).toHaveBeenCalledWith(parentNodeId)
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
})
})

View File

@@ -24,7 +24,10 @@ import {
} from '@/renderer/core/layout/utils/geometry'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
import {
scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM
} from './useSlotElementTracking'
/**
* Generic update item for element bounds tracking
@@ -47,14 +50,14 @@ interface CachedNodeMeasurement {
interface ElementTrackingConfig {
/** Data attribute name (e.g., 'nodeId') */
dataAttribute: string
/** Handler for processing bounds updates */
updateHandler: (updates: ElementBoundsUpdate[]) => void
/** Handler for processing bounds updates. Omit for signal-only entries. */
updateHandler?: (updates: ElementBoundsUpdate[]) => void
}
/**
* Registry of tracking configurations by element type
*/
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
const trackingConfigs = new Map<string, ElementTrackingConfig>([
[
'node',
{
@@ -67,7 +70,10 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
layoutStore.batchUpdateNodeBounds(nodeUpdates)
}
}
]
],
// Signal-only: outer node stays at its persisted min-h floor during
// widget hydration, so the inner grid's RO is the only slot-drift signal.
['widgets-grid', { dataAttribute: 'widgetsGridNodeId' }]
])
// Elements whose ResizeObserver fired while the tab was hidden
@@ -121,6 +127,14 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!(entry.target instanceof HTMLElement)) continue
const element = entry.target
// Signal-only widgets-grid resize - route the parent node through the
// slot-layout pipeline and skip bounds processing entirely.
const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId
if (widgetsGridParentNodeId) {
scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId)
continue
}
// Find which type this element belongs to
let elementType: string | undefined
let elementId: string | undefined
@@ -238,7 +252,7 @@ const resizeObserver = new ResizeObserver((entries) => {
// Flush per-type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)
if (config && updates.length) config.updateHandler(updates)
if (config?.updateHandler && updates.length) config.updateHandler(updates)
}
}

View File

@@ -436,4 +436,30 @@ describe('ComfyApp', () => {
)
})
})
describe('drop handler', () => {
it('syncs graph_mouse from the drop event before downstream handlers run', async () => {
// graph_mouse is only updated on mousemove, so when files are dragged in
// from another window the canvas-space cursor is stale. The drop handler
// must derive the position from the drop event itself.
const graphMouse: [number, number] = [-999, -999]
const adjustMouseEvent = vi.fn((e: DragEvent) => {
;(e as DragEvent & { canvasX: number; canvasY: number }).canvasX = 123
;(e as DragEvent & { canvasX: number; canvasY: number }).canvasY = 456
})
app.canvas = {
...mockCanvas,
graph_mouse: graphMouse,
adjustMouseEvent
} as unknown as LGraphCanvas
;(app as unknown as { addDropHandler(): void }).addDropHandler()
document.dispatchEvent(new DragEvent('drop'))
await Promise.resolve()
expect(adjustMouseEvent).toHaveBeenCalledTimes(1)
expect(graphMouse).toEqual([123, 456])
})
})
})

View File

@@ -591,6 +591,13 @@ export class ComfyApp {
event.preventDefault()
event.stopPropagation()
// graph_mouse is only updated on mousemove, so when files are dragged
// in from another window the canvas-space cursor is stale. Sync it
// from the drop event so nodes created below land at the cursor.
this.canvas.adjustMouseEvent(event)
this.canvas.graph_mouse[0] = event.canvasX
this.canvas.graph_mouse[1] = event.canvasY
const n = this.dragOverNode
this.dragOverNode = null
// Node handles file drop, we dont use the built in onDropFile handler as its buggy

View File

@@ -1,43 +1,600 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: { canvas: undefined },
ComfyApp: class {}
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
async function invokeMenuCallback(option: IContextMenuValue): Promise<void> {
// Production callbacks under test do not reference `this`; ContextMenuDivElement
// is a DOM element decorated with extra fields, not realistic to construct in tests.
await option.callback?.call({} as ContextMenuDivElement)
}
const mockPrompt = vi.fn()
const mockCanvas = vi.hoisted(() => ({
setDirty: vi.fn(),
graph_mouse: [100, 200],
ds: {
scale: 1,
offset: [0, 0] as [number, number],
visible_area: [0, 0, 800, 600] as
| [number, number, number, number]
| undefined,
fitToBounds: vi.fn()
},
graph: {
nodes: [] as unknown[],
getNodeById: vi.fn(),
add: vi.fn(),
setDirtyCanvas: vi.fn(),
isRootGraph: true
},
animateToBounds: vi.fn(),
_deserializeItems: vi.fn()
}))
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
const mockApp = vi.hoisted(() => ({
canvas: undefined as unknown,
graph: undefined as unknown,
dragOverNode: null,
lastExecutionError: null,
rootGraph: {}
}))
describe('useLitegraphService().getCanvasCenter', () => {
const mockFavoritedWidgetsStore = vi.hoisted(() => ({
isFavorited: vi.fn().mockReturnValue(false),
toggleFavorite: vi.fn()
}))
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => mockFavoritedWidgetsStore
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
prompt: mockPrompt
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: mockCanvas,
getCanvas: () => mockCanvas
})
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
addWidgetPromotionOptions: vi.fn(),
isPreviewPseudoWidget: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
st: (_key: string, fallback: string) => fallback
}))
vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: (key: string) => key
}))
vi.mock('@/scripts/app', () => ({
app: mockApp,
ComfyApp: {
clipspace: null,
clipspace_return_node: null,
copyToClipspace: vi.fn(),
pasteFromClipspace: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: vi.fn() })
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ widgets: new Map() })
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
nodeLocationProgressStates: {}
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeSubgraph: null,
nodeIdToNodeLocatorId: (id: string) => id
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue(false)
})
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
toggleSelectedNodesMode: vi.fn()
})
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
invokeExtensionsAsync: vi.fn()
})
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
typePrefix: 'Subgraph::',
getBlueprint: vi.fn()
})
}))
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeOutputs: vi.fn(),
getNodePreviews: vi.fn()
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/composables/node/useNodeAnimatedImage', () => ({
useNodeAnimatedImage: () => ({
showAnimatedPreview: vi.fn(),
removeAnimatedPreview: vi.fn()
})
}))
vi.mock('@/composables/node/useNodeCanvasImagePreview', () => ({
useNodeCanvasImagePreview: () => ({
showCanvasImagePreview: vi.fn(),
removeCanvasImagePreview: vi.fn()
})
}))
vi.mock('@/composables/node/useNodeImage', () => ({
useNodeImage: () => ({ showPreview: vi.fn() }),
useNodeVideo: () => ({ showPreview: vi.fn() })
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({ unpackSubgraph: vi.fn() })
}))
vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
useMaskEditor: () => ({ openMaskEditor: vi.fn() })
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
registerWidget: vi.fn(),
unregisterWidget: vi.fn()
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
getPromotionsRef: vi.fn().mockReturnValue([])
})
}))
vi.mock('@/services/subgraphPseudoWidgetCache', () => ({
resolveSubgraphPseudoWidgetCache: vi.fn().mockReturnValue({
cache: { promotions: [], entries: [], nodes: [] },
nodes: []
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ openPanel: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
vi.mock('@/scripts/domWidget', () => ({
isComponentWidget: vi.fn().mockReturnValue(false),
isDOMWidget: vi.fn().mockReturnValue(false)
}))
const mockCreateBounds = vi.hoisted(() => vi.fn())
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
createBounds: mockCreateBounds
}
})
vi.mock('@/scripts/ui', () => ({
$el: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn().mockReturnValue(false),
isImageNode: vi.fn().mockReturnValue(false),
isVideoNode: vi.fn().mockReturnValue(false),
isVideoOutput: vi.fn().mockReturnValue(false),
migrateWidgetsValues: vi.fn().mockReturnValue([])
}))
vi.mock('@/core/graph/widgets/dynamicWidgets', () => ({
applyDynamicInputs: vi.fn().mockReturnValue(false)
}))
vi.mock('@/schemas/nodeDef/migration', () => ({
transformInputSpecV2ToV1: vi.fn().mockReturnValue([])
}))
vi.mock('@/workbench/utils/nodeDefOrderingUtil', () => ({
getOrderedInputSpecs: vi.fn().mockReturnValue([])
}))
vi.mock('@/stores/nodeDefStore', () => ({
ComfyNodeDefImpl: vi.fn().mockImplementation((def: unknown) => def)
}))
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
const node = new LGraphNode('TestNode')
Object.assign(node, {
id: 1,
inputs: [],
graph: null,
getWidgetOnPos: vi.fn()
})
Object.assign(node, overrides)
// Set static nodeData for tests that check constructor.nodeData
;(node.constructor as { nodeData?: { name: string } }).nodeData = {
name: 'TestNode'
}
return node
}
function createMockWidget(
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
name: 'test_widget',
label: undefined,
value: 42,
callback: vi.fn(),
options: {},
...overrides
} as unknown as IBaseWidget
}
describe('litegraphService', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
mockPrompt.mockReset()
mockCreateBounds.mockReset()
mockCanvas.graph.getNodeById.mockReset()
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.visible_area = [0, 0, 800, 600]
mockCanvas.graph.nodes = []
mockApp.canvas = mockCanvas
mockApp.graph = mockCanvas.graph
})
it('returns origin when canvas is not yet initialised', () => {
Reflect.set(app, 'canvas', undefined)
describe('getExtraOptionsForWidget', () => {
it('adds favorite option when widget is not favorited', () => {
const node = createMockNode()
const widget = createMockWidget()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
const center = useLitegraphService().getCanvasCenter()
const options = getExtraOptionsForWidget(node, widget)
expect(center).toEqual([0, 0])
})
it('returns origin when canvas exists but ds.visible_area is missing', () => {
Reflect.set(app, 'canvas', { ds: {} })
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns the visible-area centre once the canvas is ready', () => {
Reflect.set(app, 'canvas', {
ds: { visible_area: [10, 20, 200, 100] }
expect(options).toHaveLength(1)
expect(options[0].content).toContain('contextMenu.FavoriteWidget')
expect(options[0].content).toContain('test_widget')
})
const center = useLitegraphService().getCanvasCenter()
it('adds unfavorite option when widget is already favorited', () => {
const node = createMockNode()
const widget = createMockWidget()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(true)
expect(center).toEqual([110, 70])
const options = getExtraOptionsForWidget(node, widget)
expect(options[0].content).toContain('contextMenu.UnfavoriteWidget')
})
it('uses widget label when available', () => {
const node = createMockNode()
const widget = createMockWidget({ label: 'My Label' })
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
const options = getExtraOptionsForWidget(node, widget)
expect(options[0].content).toContain('My Label')
})
it('calls toggleFavorite when favorite option callback is invoked', () => {
const node = createMockNode()
const widget = createMockWidget()
const options = getExtraOptionsForWidget(node, widget)
void invokeMenuCallback(options[0])
expect(mockFavoritedWidgetsStore.toggleFavorite).toHaveBeenCalledWith(
node,
'test_widget'
)
})
it('adds rename option when input matches widget', () => {
const widget = createMockWidget({ name: 'seed' })
const node = createMockNode({
inputs: [{ widget: { name: 'seed' } }]
})
const options = getExtraOptionsForWidget(node, widget)
// rename is unshifted first, then favorite is unshifted (ends up first)
expect(options).toHaveLength(2)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
expect(renameOption).toBeDefined()
expect(renameOption!.content).toContain('seed')
})
it('rename callback updates widget and input labels', async () => {
const widget = createMockWidget({ name: 'seed' })
const input = { widget: { name: 'seed' }, label: undefined as unknown }
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue('New Name')
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBe('New Name')
expect(input.label).toBe('New Name')
expect(widget.callback).toHaveBeenCalledWith(42)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('rename callback clears label when empty string is returned', async () => {
const widget = createMockWidget({ name: 'seed', label: 'Old' })
const input = {
widget: { name: 'seed' },
label: 'Old' as string | undefined
}
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue('')
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBeUndefined()
expect(input.label).toBeUndefined()
})
it('rename callback does nothing when prompt is cancelled', async () => {
const widget = createMockWidget({ name: 'seed', label: 'Original' })
const input = { widget: { name: 'seed' }, label: 'Original' }
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue(null)
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBe('Original')
expect(input.label).toBe('Original')
})
it('adds promotion options when node is in a subgraph', async () => {
const { addWidgetPromotionOptions } = vi.mocked(
await import('@/core/graph/subgraph/promotionUtils')
)
const node = createMockNode({
graph: { isRootGraph: false }
})
const widget = createMockWidget()
getExtraOptionsForWidget(node, widget)
expect(addWidgetPromotionOptions).toHaveBeenCalled()
})
it('does not add promotion options on root graph', async () => {
const { addWidgetPromotionOptions } = vi.mocked(
await import('@/core/graph/subgraph/promotionUtils')
)
const node = createMockNode({ graph: null })
const widget = createMockWidget()
getExtraOptionsForWidget(node, widget)
expect(addWidgetPromotionOptions).not.toHaveBeenCalled()
})
})
describe('useLitegraphService', () => {
// Lazily import to ensure mocks are in place
async function getService() {
const { useLitegraphService } =
await import('@/services/litegraphService')
return useLitegraphService()
}
describe('getCanvasCenter', () => {
it('returns center of visible area', async () => {
const service = await getService()
// visible_area = [0, 0, 800, 600], dpi = 1
const center = service.getCanvasCenter()
expect(center).toEqual([400, 300])
})
it('accounts for visible area offset', async () => {
const saved = mockCanvas.ds.visible_area
mockCanvas.ds.visible_area = [10, 20, 200, 100]
const service = await getService()
const center = service.getCanvasCenter()
expect(center).toEqual([110, 70])
mockCanvas.ds.visible_area = saved
})
it('returns [0, 0] when no visible area', async () => {
const savedVisibleArea = mockCanvas.ds.visible_area
mockCanvas.ds.visible_area = undefined
const service = await getService()
const center = service.getCanvasCenter()
expect(center).toEqual([0, 0])
mockCanvas.ds.visible_area = savedVisibleArea
})
it('returns [0, 0] without throwing when app.canvas is undefined', async () => {
mockApp.canvas = undefined
const service = await getService()
expect(() => service.getCanvasCenter()).not.toThrow()
expect(service.getCanvasCenter()).toEqual([0, 0])
})
})
describe('resetView', () => {
it('resets canvas scale and offset', async () => {
mockCanvas.ds.scale = 2.5
mockCanvas.ds.offset = [100, 200]
const service = await getService()
service.resetView()
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
})
describe('goToNode', () => {
it('animates to node bounds when node exists', async () => {
const bounds = [10, 20, 100, 50]
const graphNode = { boundingRect: bounds }
mockCanvas.graph.getNodeById.mockReturnValue(graphNode)
const service = await getService()
service.goToNode(42)
expect(mockCanvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
it('does nothing when node does not exist', async () => {
mockCanvas.graph.getNodeById.mockReturnValue(null)
const service = await getService()
service.goToNode(999)
expect(mockCanvas.animateToBounds).not.toHaveBeenCalled()
})
})
describe('fitView', () => {
it('calls fitToBounds and setDirty', async () => {
const mockBounds = [0, 0, 500, 400]
mockCreateBounds.mockReturnValue(mockBounds)
const nodeObj = {
boundingRect: [0, 0, 100, 50],
updateArea: vi.fn()
}
mockCanvas.graph.nodes = [nodeObj]
const service = await getService()
service.fitView()
expect(mockCanvas.ds.fitToBounds).toHaveBeenCalledWith(mockBounds)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('calls updateArea for nodes with zero bounds', async () => {
mockCreateBounds.mockReturnValue([0, 0, 100, 100])
const nodeObj = {
boundingRect: [0, 0, 0, 0],
updateArea: vi.fn()
}
mockCanvas.graph.nodes = [nodeObj]
const service = await getService()
service.fitView()
expect(nodeObj.updateArea).toHaveBeenCalled()
})
it('does nothing when createBounds returns null', async () => {
mockCreateBounds.mockReturnValue(null)
mockCanvas.graph.nodes = []
const service = await getService()
service.fitView()
expect(mockCanvas.ds.fitToBounds).not.toHaveBeenCalled()
})
})
describe('updatePreviews', () => {
it('catches errors and logs them', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockNodeOutputStore.getNodeOutputs.mockImplementation(() => {
throw new Error('test error')
})
const service = await getService()
const badNode = createMockNode({ flags: { collapsed: false } })
expect(() => service.updatePreviews(badNode)).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith(
'Error drawing node background',
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('skips collapsed nodes', async () => {
const service = await getService()
const node = createMockNode({
flags: { collapsed: true },
imgs: undefined,
images: undefined,
preview: undefined
})
service.updatePreviews(node)
expect(mockNodeOutputStore.getNodeOutputs).not.toHaveBeenCalled()
})
})
})
})

View File

@@ -28,6 +28,7 @@ describe('getProviderIcon', () => {
it('returns icon class for simple provider name', () => {
expect(getProviderIcon('BFL')).toBe('icon-[comfy--bfl]')
expect(getProviderIcon('OpenAI')).toBe('icon-[comfy--openai]')
expect(getProviderIcon('Anthropic')).toBe('icon-[comfy--anthropic]')
})
it('converts spaces to hyphens', () => {
@@ -47,6 +48,7 @@ describe('getProviderBorderStyle', () => {
expect(getProviderBorderStyle('BFL')).toBe('#ffffff')
expect(getProviderBorderStyle('OpenAI')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Bria')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Anthropic')).toBe('#D97757')
})
it('returns gradient for dual-color providers', () => {

View File

@@ -56,6 +56,7 @@ export const getCategoryIcon = (categoryId: string): string => {
* Each entry can be a single color or [color1, color2] for gradient.
*/
const PROVIDER_COLORS: Record<string, string | [string, string]> = {
anthropic: '#D97757',
bfl: '#ffffff',
bria: '#B6B6B6',
elevenlabs: '#B6B6B6',

View File

@@ -1,9 +1,20 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { resolveNode } from './litegraphUtil'
import { createNode, resolveNode } from './litegraphUtil'
const mockBringNodeToFront = vi.fn()
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeZIndex', () => ({
useNodeZIndex: () => ({ bringNodeToFront: mockBringNodeToFront })
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: vi.fn() })
}))
describe('resolveNode', () => {
it('returns undefined when graph is null', () => {
@@ -68,3 +79,66 @@ describe('resolveNode', () => {
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
})
})
describe('createNode', () => {
function makeCanvas(graph: LGraph): LGraphCanvas {
return {
graph,
graph_mouse: [100, 200] as [number, number]
} as Partial<LGraphCanvas> as LGraphCanvas
}
beforeEach(() => {
mockBringNodeToFront.mockClear()
})
it('returns null when name is empty', async () => {
const result = await createNode(makeCanvas(new LGraph()), '')
expect(result).toBeNull()
expect(mockBringNodeToFront).not.toHaveBeenCalled()
})
it('places the new node at the canvas graph_mouse position', async () => {
const newNode = new LGraphNode('LoadImage')
const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(newNode)
const graph = new LGraph()
const result = await createNode(makeCanvas(graph), 'LoadImage')
expect(result).toBe(newNode)
expect(Array.from(newNode.pos)).toEqual([100, 200])
spy.mockRestore()
})
it('brings the new node to front so it renders above existing nodes', async () => {
const newNode = new LGraphNode('LoadImage')
const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(newNode)
const graph = new LGraph()
const result = await createNode(makeCanvas(graph), 'LoadImage')
expect(result).toBe(newNode)
expect(mockBringNodeToFront).toHaveBeenCalledTimes(1)
expect(mockBringNodeToFront).toHaveBeenCalledWith(newNode.id)
spy.mockRestore()
})
it('does not bring node to front when LiteGraph.createNode returns null', async () => {
const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(null)
await createNode(makeCanvas(new LGraph()), 'NonexistentNode')
expect(mockBringNodeToFront).not.toHaveBeenCalled()
spy.mockRestore()
})
it('does not bring node to front when graph.add returns null', async () => {
const newNode = new LGraphNode('LoadImage')
const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(newNode)
const graph = new LGraph()
vi.spyOn(graph, 'add').mockReturnValue(null as unknown as LGraphNode)
await createNode(makeCanvas(graph), 'LoadImage')
expect(mockBringNodeToFront).not.toHaveBeenCalled()
spy.mockRestore()
})
})

View File

@@ -24,6 +24,7 @@ import type {
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { app } from '@/scripts/app'
import { t } from '@/i18n'
@@ -57,7 +58,10 @@ export async function createNode(
newNode.pos = [posX, posY]
const addedNode = graph.add(newNode) ?? null
if (addedNode) graph.change()
if (addedNode) {
useNodeZIndex().bringNodeToFront(addedNode.id)
graph.change()
}
return addedNode
} else {
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import ProgressSpinner from 'primevue/progressspinner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { render, screen } from '@testing-library/vue'
@@ -56,7 +57,8 @@ vi.mock('@vueuse/core', () => ({
createSharedComposable: vi.fn((fn) => {
let cached: ReturnType<typeof fn>
return (...args: Parameters<typeof fn>) => (cached ??= fn(...args))
})
}),
useDocumentVisibility: vi.fn(() => ref<'visible' | 'hidden'>('visible'))
}))
vi.mock('@/config', () => ({