From 8340d7655fe8f34132c23d45cc7968ea9cc58714 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 29 Mar 2026 17:18:49 -0700 Subject: [PATCH 001/218] refactor: extract auth-routing from workspaceApi to auth domain (#10484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extract auth-routing logic (`getAuthHeaderOrThrow`, `getFirebaseAuthHeaderOrThrow`) from `workspaceApi.ts` into `authStore.ts`, eliminating a layering violation where the workspace API re-implemented auth header resolution. ## Changes - **What**: Moved `getAuthHeaderOrThrow` and `getFirebaseAuthHeaderOrThrow` from `workspaceApi.ts` to `authStore.ts`. `workspaceApi.ts` now calls through `useAuthStore()` instead of re-implementing token resolution. Added tests for the new methods in `authStore.test.ts`. Updated `authStoreMock.ts` with the new methods. - **Files**: 4 files changed ## Review Focus - The `getAuthHeaderOrThrow` / `getFirebaseAuthHeaderOrThrow` methods throw `AuthStoreError` (auth domain error) — callers in workspace can catch and re-wrap if needed - `workspaceApi.ts` is simplified by ~19 lines ## Stack PR 2/5: #10483 → **→ This PR** → #10485 → #10486 → #10487 --- src/platform/workspace/api/workspaceApi.ts | 25 ++-------------- src/stores/authStore.test.ts | 33 ++++++++++++++++++++++ src/stores/authStore.ts | 18 ++++++++++++ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 5b176a16b2..8333c7b6e6 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -1,6 +1,5 @@ import axios from 'axios' -import { t } from '@/i18n' import { api } from '@/scripts/api' import { useAuthStore } from '@/stores/authStore' @@ -288,27 +287,7 @@ const workspaceApiClient = axios.create({ }) async function getAuthHeaderOrThrow() { - const authHeader = await useAuthStore().getAuthHeader() - if (!authHeader) { - throw new WorkspaceApiError( - t('toastMessages.userNotAuthenticated'), - 401, - 'NOT_AUTHENTICATED' - ) - } - return authHeader -} - -async function getFirebaseHeaderOrThrow() { - const authHeader = await useAuthStore().getFirebaseAuthHeader() - if (!authHeader) { - throw new WorkspaceApiError( - t('toastMessages.userNotAuthenticated'), - 401, - 'NOT_AUTHENTICATED' - ) - } - return authHeader + return useAuthStore().getAuthHeaderOrThrow() } function handleAxiosError(err: unknown): never { @@ -500,7 +479,7 @@ export const workspaceApi = { * Uses Firebase auth (user identity) since the user isn't yet a workspace member. */ async acceptInvite(token: string): Promise { - const headers = await getFirebaseHeaderOrThrow() + const headers = await useAuthStore().getFirebaseAuthHeaderOrThrow() try { const response = await workspaceApiClient.post( api.apiURL(`/invites/${token}/accept`), diff --git a/src/stores/authStore.test.ts b/src/stores/authStore.test.ts index 239b1c4949..0b4080bf04 100644 --- a/src/stores/authStore.test.ts +++ b/src/stores/authStore.test.ts @@ -730,6 +730,39 @@ describe('useAuthStore', () => { }) }) + describe('getAuthHeaderOrThrow', () => { + it('returns auth header when authenticated', async () => { + const header = await store.getAuthHeaderOrThrow() + expect(header).toEqual({ Authorization: 'Bearer mock-id-token' }) + }) + + it('throws AuthStoreError when not authenticated', async () => { + authStateCallback(null) + mockApiKeyGetAuthHeader.mockReturnValue(null) + + await expect(store.getAuthHeaderOrThrow()).rejects.toMatchObject({ + name: 'AuthStoreError', + message: 'toastMessages.userNotAuthenticated' + }) + }) + }) + + describe('getFirebaseAuthHeaderOrThrow', () => { + it('returns Firebase auth header when authenticated', async () => { + const header = await store.getFirebaseAuthHeaderOrThrow() + expect(header).toEqual({ Authorization: 'Bearer mock-id-token' }) + }) + + it('throws AuthStoreError when not authenticated', async () => { + authStateCallback(null) + + await expect(store.getFirebaseAuthHeaderOrThrow()).rejects.toMatchObject({ + name: 'AuthStoreError', + message: 'toastMessages.userNotAuthenticated' + }) + }) + }) + describe('createCustomer', () => { it('should succeed with API key auth when no Firebase user is present', async () => { authStateCallback(null) diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 8452b91f88..82fb762434 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -236,6 +236,22 @@ export const useAuthStore = defineStore('auth', () => { return await getIdToken() } + const getAuthHeaderOrThrow = async (): Promise => { + const authHeader = await getAuthHeader() + if (!authHeader) { + throw new AuthStoreError(t('toastMessages.userNotAuthenticated')) + } + return authHeader + } + + const getFirebaseAuthHeaderOrThrow = async (): Promise => { + const authHeader = await getFirebaseAuthHeader() + if (!authHeader) { + throw new AuthStoreError(t('toastMessages.userNotAuthenticated')) + } + return authHeader + } + const fetchBalance = async (): Promise => { isFetchingBalance.value = true try { @@ -538,7 +554,9 @@ export const useAuthStore = defineStore('auth', () => { sendPasswordReset, updatePassword: _updatePassword, getAuthHeader, + getAuthHeaderOrThrow, getFirebaseAuthHeader, + getFirebaseAuthHeaderOrThrow, getAuthToken } }) From dc7c97c5ac96cca0c4e85e68a56eb8f6b2f36ca5 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 29 Mar 2026 17:30:49 -0700 Subject: [PATCH 002/218] feat: add Wave 3 homepage sections (11 Vue components) [3/3] (#10142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds all 11 homepage section components for the comfy.org marketing site. ## Changes (incremental from #10141) - HeroSection.vue: C monogram left, headline right, CTAs - SocialProofBar.vue: 12 enterprise logos + metrics - ProductShowcase.vue: PLACEHOLDER workflow demo - ValuePillars.vue: Build/Customize/Refine/Automate/Run - UseCaseSection.vue: PLACEHOLDER industries - CaseStudySpotlight.vue: PLACEHOLDER bento grid - TestimonialsSection.vue: Filterable by industry - GetStartedSection.vue: 3-step flow - CTASection.vue: Desktop/Cloud/API cards - ManifestoSection.vue: Method Not Magic - AcademySection.vue: Learning paths CTA - Updated index.astro + zh-CN/index.astro ## Stack (via Graphite) - #10140 [1/3] Scaffold - #10141 [2/3] Layout Shell - **[3/3] Homepage Sections** ← this PR (top of stack) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10142-feat-add-Wave-3-homepage-sections-11-Vue-components-3-3-3266d73d36508194aa8ee9385733ddb9) by [Unito](https://www.unito.io) --- .../website/src/components/AcademySection.vue | 47 ++++++++++ apps/website/src/components/CTASection.vue | 66 +++++++++++++ .../src/components/CaseStudySpotlight.vue | 77 ++++++++++++++++ .../src/components/GetStartedSection.vue | 62 +++++++++++++ apps/website/src/components/HeroSection.vue | 68 ++++++++++++++ .../src/components/ManifestoSection.vue | 26 ++++++ .../src/components/ProductShowcase.vue | 51 ++++++++++ .../website/src/components/SocialProofBar.vue | 58 ++++++++++++ .../src/components/TestimonialsSection.vue | 92 +++++++++++++++++++ .../website/src/components/UseCaseSection.vue | 74 +++++++++++++++ apps/website/src/components/ValuePillars.vue | 67 ++++++++++++++ apps/website/src/pages/index.astro | 34 +++++++ apps/website/src/pages/zh-CN/index.astro | 34 +++++++ 13 files changed, 756 insertions(+) create mode 100644 apps/website/src/components/AcademySection.vue create mode 100644 apps/website/src/components/CTASection.vue create mode 100644 apps/website/src/components/CaseStudySpotlight.vue create mode 100644 apps/website/src/components/GetStartedSection.vue create mode 100644 apps/website/src/components/HeroSection.vue create mode 100644 apps/website/src/components/ManifestoSection.vue create mode 100644 apps/website/src/components/ProductShowcase.vue create mode 100644 apps/website/src/components/SocialProofBar.vue create mode 100644 apps/website/src/components/TestimonialsSection.vue create mode 100644 apps/website/src/components/UseCaseSection.vue create mode 100644 apps/website/src/components/ValuePillars.vue create mode 100644 apps/website/src/pages/index.astro create mode 100644 apps/website/src/pages/zh-CN/index.astro diff --git a/apps/website/src/components/AcademySection.vue b/apps/website/src/components/AcademySection.vue new file mode 100644 index 0000000000..24e89f52e0 --- /dev/null +++ b/apps/website/src/components/AcademySection.vue @@ -0,0 +1,47 @@ + + + diff --git a/apps/website/src/components/CTASection.vue b/apps/website/src/components/CTASection.vue new file mode 100644 index 0000000000..3bd9868ef7 --- /dev/null +++ b/apps/website/src/components/CTASection.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/website/src/components/CaseStudySpotlight.vue b/apps/website/src/components/CaseStudySpotlight.vue new file mode 100644 index 0000000000..2672fedc18 --- /dev/null +++ b/apps/website/src/components/CaseStudySpotlight.vue @@ -0,0 +1,77 @@ + + + + diff --git a/apps/website/src/components/GetStartedSection.vue b/apps/website/src/components/GetStartedSection.vue new file mode 100644 index 0000000000..463aec0439 --- /dev/null +++ b/apps/website/src/components/GetStartedSection.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/website/src/components/HeroSection.vue b/apps/website/src/components/HeroSection.vue new file mode 100644 index 0000000000..5d2365850e --- /dev/null +++ b/apps/website/src/components/HeroSection.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/website/src/components/ManifestoSection.vue b/apps/website/src/components/ManifestoSection.vue new file mode 100644 index 0000000000..e3b095cae5 --- /dev/null +++ b/apps/website/src/components/ManifestoSection.vue @@ -0,0 +1,26 @@ + diff --git a/apps/website/src/components/ProductShowcase.vue b/apps/website/src/components/ProductShowcase.vue new file mode 100644 index 0000000000..ef74c85117 --- /dev/null +++ b/apps/website/src/components/ProductShowcase.vue @@ -0,0 +1,51 @@ + + + + diff --git a/apps/website/src/components/SocialProofBar.vue b/apps/website/src/components/SocialProofBar.vue new file mode 100644 index 0000000000..db3aa3a62c --- /dev/null +++ b/apps/website/src/components/SocialProofBar.vue @@ -0,0 +1,58 @@ + + + diff --git a/apps/website/src/components/TestimonialsSection.vue b/apps/website/src/components/TestimonialsSection.vue new file mode 100644 index 0000000000..9a221a584e --- /dev/null +++ b/apps/website/src/components/TestimonialsSection.vue @@ -0,0 +1,92 @@ + + + diff --git a/apps/website/src/components/UseCaseSection.vue b/apps/website/src/components/UseCaseSection.vue new file mode 100644 index 0000000000..5d0401d921 --- /dev/null +++ b/apps/website/src/components/UseCaseSection.vue @@ -0,0 +1,74 @@ + + + + diff --git a/apps/website/src/components/ValuePillars.vue b/apps/website/src/components/ValuePillars.vue new file mode 100644 index 0000000000..cdf08f4624 --- /dev/null +++ b/apps/website/src/components/ValuePillars.vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro new file mode 100644 index 0000000000..31d72f6f5e --- /dev/null +++ b/apps/website/src/pages/index.astro @@ -0,0 +1,34 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro' +import SiteNav from '../components/SiteNav.vue' +import HeroSection from '../components/HeroSection.vue' +import SocialProofBar from '../components/SocialProofBar.vue' +import ProductShowcase from '../components/ProductShowcase.vue' +import ValuePillars from '../components/ValuePillars.vue' +import UseCaseSection from '../components/UseCaseSection.vue' +import CaseStudySpotlight from '../components/CaseStudySpotlight.vue' +import TestimonialsSection from '../components/TestimonialsSection.vue' +import GetStartedSection from '../components/GetStartedSection.vue' +import CTASection from '../components/CTASection.vue' +import ManifestoSection from '../components/ManifestoSection.vue' +import AcademySection from '../components/AcademySection.vue' +import SiteFooter from '../components/SiteFooter.vue' +--- + + + +
+ + + + + + + + + + + +
+ +
diff --git a/apps/website/src/pages/zh-CN/index.astro b/apps/website/src/pages/zh-CN/index.astro new file mode 100644 index 0000000000..a9960824a3 --- /dev/null +++ b/apps/website/src/pages/zh-CN/index.astro @@ -0,0 +1,34 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro' +import SiteNav from '../../components/SiteNav.vue' +import HeroSection from '../../components/HeroSection.vue' +import SocialProofBar from '../../components/SocialProofBar.vue' +import ProductShowcase from '../../components/ProductShowcase.vue' +import ValuePillars from '../../components/ValuePillars.vue' +import UseCaseSection from '../../components/UseCaseSection.vue' +import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue' +import TestimonialsSection from '../../components/TestimonialsSection.vue' +import GetStartedSection from '../../components/GetStartedSection.vue' +import CTASection from '../../components/CTASection.vue' +import ManifestoSection from '../../components/ManifestoSection.vue' +import AcademySection from '../../components/AcademySection.vue' +import SiteFooter from '../../components/SiteFooter.vue' +--- + + + +
+ + + + + + + + + + + +
+ +
From c289640e9922e4ee0f610271bd491739c32379c0 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Mon, 30 Mar 2026 09:47:48 +0900 Subject: [PATCH 003/218] 1.43.10 (#10726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.43.10 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10726-1-43-10-3336d73d36508179a69cf7affcc0070e) by [Unito](https://www.unito.io) --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Christian Byrne --- package.json | 2 +- src/locales/ar/nodeDefs.json | 4 ++++ src/locales/en/nodeDefs.json | 4 ++++ src/locales/es/nodeDefs.json | 4 ++++ src/locales/fa/nodeDefs.json | 4 ++++ src/locales/fr/nodeDefs.json | 4 ++++ src/locales/ja/nodeDefs.json | 4 ++++ src/locales/ko/nodeDefs.json | 4 ++++ src/locales/pt-BR/nodeDefs.json | 4 ++++ src/locales/ru/nodeDefs.json | 4 ++++ src/locales/tr/nodeDefs.json | 4 ++++ src/locales/zh-TW/nodeDefs.json | 4 ++++ src/locales/zh/nodeDefs.json | 4 ++++ 13 files changed, 49 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c95589bd58..602c264492 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.43.9", + "version": "1.43.10", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index f77038194d..9ea289cb2d 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "صورة UV", + "tooltip": null } } }, diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 4795f8bc5a..1c6e97166b 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 333c6df0d3..248b7e3139 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "imagen UV", + "tooltip": null } } }, diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 6767528158..cb1efe2fa6 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "تصویر UV", + "tooltip": null } } }, diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index 7d2a15a777..58341e288f 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index e265aefab2..cd28888017 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv画像", + "tooltip": null } } }, diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index ce10409807..b12f5b504c 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 3a602c3c35..1dcb829039 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "imagem UV", + "tooltip": null } } }, diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index faf4563e9c..0eedd1cf1f 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 8dd5473ab6..d35de786c3 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_görüntüsü", + "tooltip": null } } }, diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index 08de1e3edc..018a2c35c3 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 3aa875a5ff..f6b0ff2346 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -15680,6 +15680,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, From f1d53371819829b4fbc5f4c18757a8f56848a274 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 29 Mar 2026 22:26:42 -0400 Subject: [PATCH 004/218] Feat/glsl live preview (#10349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/9201 the first commit squashed https://github.com/Comfy-Org/ComfyUI_frontend/pull/9201 and fixed conflict. the second commit change needed by: - Enable GLSL live preview on SubgraphNodes by detecting the inner GLSLShader and rendering its preview directly on the parent SubgraphNode - Previously, SubgraphNodes containing a GLSLShader showed no live preview at all To achieve this: - Read shader source, uniform values, and renderer config from the inner GLSLShader's widgets - Trace IMAGE inputs through the subgraph boundary so the inner shader can use images connected to the SubgraphNode's outer inputs - Set preview output using the inner node's locator ID so the promoted preview system picks it up on the SubgraphNode - Extract setNodePreviewsByLocatorId from nodeOutputStore to support setting previews by locator ID directly - Fix graphId to use rootGraph.id for widget store lookups (was using graph.id, which broke lookups for nodes inside subgraphs) - Read uniform values from connected upstream nodes, not just local widgets - Fix blob URL lifecycle: use the store's createSharedObjectUrl/releaseSharedObjectUrl reference-counting system instead of manual revoke, preventing leaks on composable re-creation ## Screenshot https://github.com/user-attachments/assets/9623fa32-de39-4a3a-b8b3-28688851390b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10349-Feat-glsl-live-preview-3296d73d3650814b83aef52ab1962a77) by [Unito](https://www.unito.io) --- src/components/curve/curveUtils.ts | 12 + .../vueNodes/components/LGraphNode.vue | 3 + src/renderer/glsl/glslPreviewUtils.ts | 40 ++ src/renderer/glsl/useGLSLPreview.test.ts | 331 ++++++++++++ src/renderer/glsl/useGLSLPreview.ts | 500 ++++++++++++++++++ src/renderer/glsl/useGLSLRenderer.test.ts | 61 +++ src/renderer/glsl/useGLSLRenderer.ts | 136 +++-- src/renderer/glsl/useGLSLUniforms.ts | 247 +++++++++ src/stores/nodeOutputStore.ts | 31 +- 9 files changed, 1313 insertions(+), 48 deletions(-) create mode 100644 src/renderer/glsl/glslPreviewUtils.ts create mode 100644 src/renderer/glsl/useGLSLPreview.test.ts create mode 100644 src/renderer/glsl/useGLSLPreview.ts create mode 100644 src/renderer/glsl/useGLSLRenderer.test.ts create mode 100644 src/renderer/glsl/useGLSLUniforms.ts diff --git a/src/components/curve/curveUtils.ts b/src/components/curve/curveUtils.ts index 4254878dd2..ebf2cde2a7 100644 --- a/src/components/curve/curveUtils.ts +++ b/src/components/curve/curveUtils.ts @@ -192,3 +192,15 @@ export function curvesToLUT( return lut } + +export function curveDataToFloatLUT( + curve: CurveData, + size: number = 256 +): Float32Array { + const lut = new Float32Array(size) + const interpolate = createInterpolator(curve.points, curve.interpolation) + for (let i = 0; i < size; i++) { + lut[i] = interpolate(i / (size - 1)) + } + return lut +} diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index ef85607c09..e1af588e57 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -284,6 +284,7 @@ import { useTelemetry } from '@/platform/telemetry' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview' import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews' import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue' import { LayoutSource } from '@/renderer/core/layout/types' @@ -730,6 +731,8 @@ const lgraphNode = computed(() => { // reaching through lgraphNode for promoted preview resolution. const { promotedPreviews } = usePromotedPreviews(lgraphNode) +useGLSLPreview(lgraphNode) + const showAdvancedInputsButton = computed(() => { const node = lgraphNode.value if (!node) return false diff --git a/src/renderer/glsl/glslPreviewUtils.ts b/src/renderer/glsl/glslPreviewUtils.ts new file mode 100644 index 0000000000..345de51895 --- /dev/null +++ b/src/renderer/glsl/glslPreviewUtils.ts @@ -0,0 +1,40 @@ +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants' + +export const GLSL_NODE_TYPE = 'GLSLShader' +export const DEBOUNCE_MS = 50 +export const DEFAULT_SIZE = 512 +const MAX_PREVIEW_DIMENSION = 1024 + +export function normalizeDimension(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SIZE + return parsed +} + +export function clampResolution(w: number, h: number): [number, number] { + const maxDim = Math.max(w, h) + if (maxDim <= MAX_PREVIEW_DIMENSION) return [w, h] + const scale = MAX_PREVIEW_DIMENSION / maxDim + return [Math.round(w * scale), Math.round(h * scale)] +} + +export function getImageThroughSubgraphBoundary( + node: LGraphNode, + slot: number, + ownerSubgraphNode: LGraphNode +): HTMLImageElement | undefined { + const graph = node.graph + if (!graph) return undefined + + const input = node.inputs[slot] + if (input?.link == null) return undefined + + const link = graph._links.get(input.link) + if (!link || link.origin_id !== SUBGRAPH_INPUT_ID) return undefined + + const outerUpstream = ownerSubgraphNode.getInputNode(link.origin_slot) + if (!outerUpstream?.imgs?.length) return undefined + + return outerUpstream.imgs[0] +} diff --git a/src/renderer/glsl/useGLSLPreview.test.ts b/src/renderer/glsl/useGLSLPreview.test.ts new file mode 100644 index 0000000000..3029c21761 --- /dev/null +++ b/src/renderer/glsl/useGLSLPreview.test.ts @@ -0,0 +1,331 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, reactive, ref, shallowRef } from 'vue' + +import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview' +import { useWidgetValueStore } from '@/stores/widgetValueStore' + +import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { MaybeRefOrGetter } from 'vue' + +const mockRendererFactory = vi.hoisted(() => { + const init = vi.fn(() => true) + const compileFragment = vi.fn(() => ({ success: true, log: '' })) + const setResolution = vi.fn() + const setFloatUniform = vi.fn() + const setIntUniform = vi.fn() + const setBoolUniform = vi.fn() + const bindCurveTexture = vi.fn() + const bindInputImage = vi.fn() + const render = vi.fn() + const toBlob = vi.fn(() => Promise.resolve(new Blob(['test']))) + const dispose = vi.fn() + const lastConfig = { value: undefined as GLSLRendererConfig | undefined } + + return { + create: (config?: GLSLRendererConfig) => { + lastConfig.value = config + return { + init, + compileFragment, + setResolution, + setFloatUniform, + setIntUniform, + setBoolUniform, + bindCurveTexture, + bindInputImage, + render, + toBlob, + dispose + } + }, + lastConfig, + init, + compileFragment, + setResolution, + setFloatUniform, + setIntUniform, + setBoolUniform, + bindCurveTexture, + bindInputImage, + render, + toBlob, + dispose + } +}) + +vi.mock('@/renderer/glsl/useGLSLRenderer', () => ({ + useGLSLRenderer: (config?: GLSLRendererConfig) => + mockRendererFactory.create(config) +})) + +const mockSetNodePreviewsByNodeId = vi.fn() +const mockNodeOutputs = reactive>({}) + +vi.mock('@/stores/nodeOutputStore', () => ({ + useNodeOutputStore: () => ({ + setNodePreviewsByNodeId: mockSetNodePreviewsByNodeId, + setNodePreviewsByLocatorId: vi.fn(), + revokePreviewsByLocatorId: vi.fn(), + nodeOutputs: mockNodeOutputs + }) +})) + +vi.mock('@/stores/widgetValueStore', () => { + const widgetMap = new Map() + const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) => + widgetMap.get(name) + ) + return { + useWidgetValueStore: () => ({ + getWidget, + _widgetMap: widgetMap + }) + } +}) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + nodeIdToNodeLocatorId: (id: string | number) => String(id), + nodeToNodeLocatorId: (node: { id: string | number }) => String(node.id) + }) +})) + +vi.mock('@/utils/objectUrlUtil', () => ({ + createSharedObjectUrl: () => 'blob:test', + releaseSharedObjectUrl: vi.fn() +})) + +function createMockNode(overrides: Record = {}): LGraphNode { + const graph = { id: 'test-graph-id', rootGraph: { id: 'test-graph-id' } } + return { + id: 1, + type: 'GLSLShader', + inputs: [], + graph, + getInputNode: vi.fn(() => null), + isSubgraphNode: () => false, + ...overrides + } as unknown as LGraphNode +} + +function wrapNode( + node: LGraphNode | null +): MaybeRefOrGetter { + return ref(node) as MaybeRefOrGetter +} + +describe('useGLSLPreview', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockRendererFactory.lastConfig.value = undefined + globalThis.URL.createObjectURL = vi.fn(() => 'blob:test') + globalThis.URL.revokeObjectURL = vi.fn() + }) + + it('does not activate for non-GLSLShader nodes', () => { + const node = createMockNode({ type: 'KSampler' }) + const { isActive } = useGLSLPreview(wrapNode(node)) + expect(isActive.value).toBe(false) + }) + + it('does not activate before first execution', () => { + const node = createMockNode() + Object.keys(mockNodeOutputs).forEach((k) => delete mockNodeOutputs[k]) + const { isActive } = useGLSLPreview(wrapNode(node)) + expect(isActive.value).toBe(false) + }) + + it('activates for GLSLShader nodes with execution output', () => { + const node = createMockNode() + mockNodeOutputs['1'] = { + images: [{ filename: 'test.png', subfolder: '', type: 'temp' }] + } + const { isActive } = useGLSLPreview(wrapNode(node)) + expect(isActive.value).toBe(true) + }) + + it('exposes lastError as null initially', () => { + const node = createMockNode() + const { lastError } = useGLSLPreview(wrapNode(node)) + expect(lastError.value).toBe(null) + }) + + it('does not activate for null node', () => { + const { isActive } = useGLSLPreview(wrapNode(null)) + expect(isActive.value).toBe(false) + }) + + it('cleans up on dispose', () => { + const node = createMockNode() + const { dispose } = useGLSLPreview(wrapNode(node)) + expect(() => dispose()).not.toThrow() + }) + + describe('autogrow config extraction', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function triggerRender(node: LGraphNode) { + mockNodeOutputs[String(node.id)] = { + images: [{ filename: 'test.png', subfolder: '', type: 'temp' }] + } + const store = useWidgetValueStore() as unknown as { + _widgetMap: Map + } + store._widgetMap.set('fragment_shader', { + value: 'void main() {}' + }) + + const nodeRef = shallowRef(null) + useGLSLPreview(nodeRef) + + nodeRef.value = node + await nextTick() + vi.advanceTimersByTime(100) + await nextTick() + } + + it('passes default config when node has no comfyDynamic', async () => { + const node = createMockNode() + await triggerRender(node) + + expect(mockRendererFactory.lastConfig.value).toEqual({ + maxInputs: 5, + maxFloatUniforms: 20, + maxIntUniforms: 20, + maxBoolUniforms: 10, + maxCurves: 4 + }) + }) + + it('extracts autogrow limits from node comfyDynamic', async () => { + const node = createMockNode({ + comfyDynamic: { + autogrow: { + images: { min: 1, max: 3 }, + floats: { min: 0, max: 8 }, + ints: { min: 0, max: 4 } + } + } + }) + await triggerRender(node) + + expect(mockRendererFactory.lastConfig.value).toEqual({ + maxInputs: 3, + maxFloatUniforms: 8, + maxIntUniforms: 4, + maxBoolUniforms: 10, + maxCurves: 4 + }) + }) + }) + + describe('render pipeline', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function setupAndRender(node: LGraphNode) { + mockNodeOutputs[String(node.id)] = { + images: [{ filename: 'test.png', subfolder: '', type: 'temp' }] + } + const store = useWidgetValueStore() as unknown as { + _widgetMap: Map + } + store._widgetMap.set('fragment_shader', { + value: 'void main() {}' + }) + + const nodeRef = shallowRef(null) + const result = useGLSLPreview(nodeRef) + + nodeRef.value = node + await nextTick() + vi.advanceTimersByTime(100) + await nextTick() + // Allow async renderPreview to complete + await nextTick() + + return result + } + + it('calls compileFragment, render, and toBlob in sequence', async () => { + const node = createMockNode() + await setupAndRender(node) + + expect(mockRendererFactory.compileFragment).toHaveBeenCalledWith( + 'void main() {}' + ) + expect(mockRendererFactory.render).toHaveBeenCalled() + expect(mockRendererFactory.toBlob).toHaveBeenCalled() + + const compileOrder = + mockRendererFactory.compileFragment.mock.invocationCallOrder[0] + const renderOrder = mockRendererFactory.render.mock.invocationCallOrder[0] + const toBlobOrder = mockRendererFactory.toBlob.mock.invocationCallOrder[0] + expect(compileOrder).toBeLessThan(renderOrder) + expect(renderOrder).toBeLessThan(toBlobOrder) + }) + + it('sets lastError on compilation failure', async () => { + mockRendererFactory.compileFragment.mockReturnValueOnce({ + success: false, + log: 'syntax error at line 5' + }) + + const node = createMockNode() + const { lastError } = await setupAndRender(node) + + expect(lastError.value).toBe('syntax error at line 5') + }) + + it('clears lastError on successful compilation', async () => { + const node = createMockNode() + const { lastError } = await setupAndRender(node) + + expect(lastError.value).toBe(null) + }) + + it('skips render when shader source is unavailable', async () => { + const store = useWidgetValueStore() as unknown as { + _widgetMap: Map + } + store._widgetMap.delete('fragment_shader') + + const node = createMockNode() + mockNodeOutputs[String(node.id)] = { + images: [{ filename: 'test.png', subfolder: '', type: 'temp' }] + } + + const nodeRef = shallowRef(null) + useGLSLPreview(nodeRef) + nodeRef.value = node + await nextTick() + vi.advanceTimersByTime(100) + await nextTick() + + expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled() + }) + + it('disposes renderer and cancels debounce on cleanup', async () => { + const node = createMockNode() + const { dispose } = await setupAndRender(node) + + dispose() + + expect(mockRendererFactory.dispose).toHaveBeenCalled() + }) + }) +}) diff --git a/src/renderer/glsl/useGLSLPreview.ts b/src/renderer/glsl/useGLSLPreview.ts new file mode 100644 index 0000000000..f20fdd10ee --- /dev/null +++ b/src/renderer/glsl/useGLSLPreview.ts @@ -0,0 +1,500 @@ +import { debounce } from 'es-toolkit/compat' +import { computed, effectScope, onScopeDispose, ref, toValue, watch } from 'vue' + +import type { ComputedRef, EffectScope, MaybeRefOrGetter, Ref } from 'vue' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph' +import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useNodeOutputStore } from '@/stores/nodeOutputStore' +import { useWidgetValueStore } from '@/stores/widgetValueStore' + +import { curveDataToFloatLUT } from '@/components/curve/curveUtils' +import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer' +import { useGLSLRenderer } from '@/renderer/glsl/useGLSLRenderer' +import { + extractUniformSources, + getAutogrowLimits, + useGLSLUniforms +} from '@/renderer/glsl/useGLSLUniforms' +import { + createSharedObjectUrl, + releaseSharedObjectUrl +} from '@/utils/objectUrlUtil' + +import { + clampResolution, + DEBOUNCE_MS, + DEFAULT_SIZE, + getImageThroughSubgraphBoundary, + GLSL_NODE_TYPE, + normalizeDimension +} from '@/renderer/glsl/glslPreviewUtils' + +/** + * Two-tier composable for GLSL live preview. + * + * Outer tier (always created): only 2 cheap computed refs to detect + * whether the node is GLSL-related. For non-GLSL nodes this is the + * only cost — no watchers, store subscriptions, or renderer. + * + * Inner tier (lazy): created via effectScope when the node is detected + * as a GLSLShader or a subgraph containing one. Contains all the + * expensive logic: store reads, watchers, debounce, WebGL renderer. + */ +export function useGLSLPreview( + nodeMaybe: MaybeRefOrGetter +) { + const lastError = ref(null) + + const nodeRef = computed(() => toValue(nodeMaybe) ?? null) + + const isGLSLNode = computed(() => nodeRef.value?.type === GLSL_NODE_TYPE) + + const isGLSLSubgraphNode = computed(() => { + const node = nodeRef.value + if (!node?.isSubgraphNode()) return false + const subgraph = node.subgraph as Subgraph | undefined + return subgraph?.nodes.some((n) => n.type === GLSL_NODE_TYPE) ?? false + }) + + const isGLSLRelated = computed( + () => isGLSLNode.value || isGLSLSubgraphNode.value + ) + + let innerScope: EffectScope | null = null + let innerDispose: (() => void) | null = null + const isActive = ref(false) + + watch( + isGLSLRelated, + (related) => { + if (related && !innerScope) { + innerScope = effectScope() + innerDispose = innerScope.run(() => + createInnerPreview( + nodeRef, + isGLSLNode, + isGLSLSubgraphNode, + lastError, + isActive + ) + )! + } else if (!related && innerScope) { + innerDispose?.() + innerScope.stop() + innerScope = null + innerDispose = null + isActive.value = false + } + }, + { immediate: true } + ) + + onScopeDispose(() => { + innerDispose?.() + innerScope?.stop() + }) + + return { + isActive: computed(() => isActive.value), + lastError, + dispose() { + innerDispose?.() + innerScope?.stop() + innerScope = null + innerDispose = null + } + } +} + +/** + * Inner tier: all expensive GLSL preview logic. + * Runs inside its own effectScope so it can be created/destroyed + * independently of the component lifecycle. + * Returns a dispose function. + */ +function createInnerPreview( + nodeRef: ComputedRef, + isGLSLNode: ComputedRef, + isGLSLSubgraphNode: ComputedRef, + lastError: Ref, + isActiveOut: Ref +): () => void { + const widgetValueStore = useWidgetValueStore() + const nodeOutputStore = useNodeOutputStore() + const { nodeToNodeLocatorId } = useWorkflowStore() + + let renderer: ReturnType | null = null + let rendererReady = false + let renderRequestId = 0 + + const innerGLSLNode = (() => { + const node = nodeRef.value + if (!node?.isSubgraphNode()) return null + const subgraph = node.subgraph as Subgraph | undefined + return subgraph?.nodes.find((n) => n.type === GLSL_NODE_TYPE) ?? null + })() + + const ownerSubgraphNode = (() => { + const node = nodeRef.value + const graph = node?.graph + if (!graph) return null + const rootGraph = graph.rootGraph + if (!rootGraph || graph === rootGraph) return null + + return ( + rootGraph._nodes?.find( + (n) => n.isSubgraphNode() && n.subgraph === graph + ) ?? null + ) + })() + + const graphId = computed( + () => nodeRef.value?.graph?.rootGraph?.id as UUID | undefined + ) + + const nodeId = computed(() => nodeRef.value?.id as NodeId | undefined) + + const hasExecutionOutput = computed(() => { + const node = nodeRef.value + if (!node) return false + + const outputs = nodeOutputStore.nodeOutputs + + const locatorId = nodeToNodeLocatorId(node) + if (outputs[locatorId]?.images?.length) return true + + const inner = innerGLSLNode + if (inner) { + const innerLocatorId = nodeToNodeLocatorId(inner) + if (outputs[innerLocatorId]?.images?.length) return true + } + + return false + }) + + const shouldRender = computed( + () => + (isGLSLNode.value || isGLSLSubgraphNode.value) && hasExecutionOutput.value + ) + + watch( + shouldRender, + (v) => { + isActiveOut.value = v + }, + { immediate: true } + ) + + const shaderSource = computed(() => { + const gId = graphId.value + if (!gId) return undefined + + if (isGLSLNode.value) { + const nId = nodeId.value + if (nId == null) return undefined + return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as + | string + | undefined + } + + const inner = innerGLSLNode + if (inner) { + return widgetValueStore.getWidget( + gId, + inner.id as NodeId, + 'fragment_shader' + )?.value as string | undefined + } + + return undefined + }) + + const rendererConfig = computed(() => { + const inner = innerGLSLNode + if (inner) return getAutogrowLimits(inner) + + const node = nodeRef.value + if (!node) + return { + maxInputs: 5, + maxFloatUniforms: 20, + maxIntUniforms: 20, + maxBoolUniforms: 10, + maxCurves: 4 + } + return getAutogrowLimits(node) + }) + + const uniformSources = computed(() => { + const node = nodeRef.value + const inner = innerGLSLNode + if (!node?.isSubgraphNode() || !inner) return null + return extractUniformSources(inner, node.subgraph as Subgraph) + }) + + const { floatValues, intValues, boolValues, curveValues } = useGLSLUniforms( + graphId, + nodeId, + nodeRef, + uniformSources, + rendererConfig + ) + + function loadInputImages(): void { + const node = nodeRef.value + if (!node?.inputs || !renderer) return + + if (isGLSLSubgraphNode.value) { + let imageSlotIndex = 0 + for (let slot = 0; slot < node.inputs.length; slot++) { + if (node.inputs[slot].type !== 'IMAGE') continue + const upstreamNode = node.getInputNode(slot) + if (upstreamNode?.imgs?.length) { + renderer.bindInputImage(imageSlotIndex, upstreamNode.imgs[0]) + } + imageSlotIndex++ + } + return + } + + let imageSlotIndex = 0 + for (let slot = 0; slot < node.inputs.length; slot++) { + const input = node.inputs[slot] + if (!input.name.startsWith('images.image')) continue + + const upstreamNode = node.getInputNode(slot) + if (upstreamNode?.imgs?.length) { + renderer.bindInputImage(imageSlotIndex, upstreamNode.imgs[0]) + imageSlotIndex++ + continue + } + + const owner = ownerSubgraphNode + if (owner) { + const img = getImageThroughSubgraphBoundary(node, slot, owner) + if (img) { + renderer.bindInputImage(imageSlotIndex, img) + } + } + imageSlotIndex++ + } + } + + function getResolution(): [number, number] { + const node = nodeRef.value + if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE] + + if (isGLSLSubgraphNode.value) { + for (let slot = 0; slot < node.inputs.length; slot++) { + if (node.inputs[slot].type !== 'IMAGE') continue + const upstreamNode = node.getInputNode(slot) + if (!upstreamNode?.imgs?.length) continue + const img = upstreamNode.imgs[0] + return clampResolution( + img.naturalWidth || DEFAULT_SIZE, + img.naturalHeight || DEFAULT_SIZE + ) + } + return [DEFAULT_SIZE, DEFAULT_SIZE] + } + + for (let slot = 0; slot < node.inputs.length; slot++) { + const input = node.inputs[slot] + if (!input.name.startsWith('images.image')) continue + + const upstreamNode = node.getInputNode(slot) + if (upstreamNode?.imgs?.length) { + const img = upstreamNode.imgs[0] + return clampResolution( + img.naturalWidth || DEFAULT_SIZE, + img.naturalHeight || DEFAULT_SIZE + ) + } + + const owner = ownerSubgraphNode + if (owner) { + const img = getImageThroughSubgraphBoundary(node, slot, owner) + if (img) { + return clampResolution( + img.naturalWidth || DEFAULT_SIZE, + img.naturalHeight || DEFAULT_SIZE + ) + } + } + } + + const gId = graphId.value + const nId = nodeId.value + if (gId && nId != null) { + const widthWidget = widgetValueStore.getWidget( + gId, + nId, + 'size_mode.width' + ) + const heightWidget = widgetValueStore.getWidget( + gId, + nId, + 'size_mode.height' + ) + if (widthWidget && heightWidget) { + return clampResolution( + normalizeDimension(widthWidget.value), + normalizeDimension(heightWidget.value) + ) + } + } + + return [DEFAULT_SIZE, DEFAULT_SIZE] + } + + let disposed = false + let lastRendererConfig: GLSLRendererConfig | null = null + + function ensureRenderer(): ReturnType { + const config = rendererConfig.value + if (renderer && lastRendererConfig) { + const changed = + config.maxInputs !== lastRendererConfig.maxInputs || + config.maxFloatUniforms !== lastRendererConfig.maxFloatUniforms || + config.maxIntUniforms !== lastRendererConfig.maxIntUniforms || + config.maxBoolUniforms !== lastRendererConfig.maxBoolUniforms || + config.maxCurves !== lastRendererConfig.maxCurves + if (changed) { + renderer.dispose() + renderer = null + rendererReady = false + } + } + if (!renderer) { + renderer = useGLSLRenderer(config) + lastRendererConfig = { ...config } + } + return renderer + } + + async function renderPreview(): Promise { + const requestId = ++renderRequestId + const source = shaderSource.value + if (!source || !shouldRender.value) return + + const r = ensureRenderer() + + try { + if (!rendererReady) { + const [w, h] = getResolution() + if (!r.init(w, h)) { + lastError.value = 'WebGL2 not available' + return + } + rendererReady = true + } + + const result = r.compileFragment(source) + if (!result.success) { + lastError.value = result.log + return + } + lastError.value = null + + const [w, h] = getResolution() + r.setResolution(w, h) + + loadInputImages() + + for (let i = 0; i < floatValues.value.length; i++) { + r.setFloatUniform(i, floatValues.value[i]) + } + for (let i = 0; i < intValues.value.length; i++) { + r.setIntUniform(i, intValues.value[i]) + } + for (let i = 0; i < boolValues.value.length; i++) { + r.setBoolUniform(i, boolValues.value[i]) + } + const curves = curveValues.value + for (let i = 0; i < curves.length; i++) { + r.bindCurveTexture(i, curveDataToFloatLUT(curves[i])) + } + + r.render() + + const blob = await r.toBlob() + if (requestId !== renderRequestId || disposed) return + const blobUrl = createSharedObjectUrl(blob) + try { + const inner = innerGLSLNode + if (inner) { + const innerLocatorId = nodeToNodeLocatorId(inner) + nodeOutputStore.setNodePreviewsByLocatorId(innerLocatorId, [blobUrl]) + } else { + const nId = nodeId.value + if (nId != null) { + nodeOutputStore.setNodePreviewsByNodeId(nId, [blobUrl]) + } + } + } finally { + releaseSharedObjectUrl(blobUrl) + } + } catch (error) { + if (requestId !== renderRequestId) return + lastError.value = + error instanceof Error ? error.message : 'Failed to render preview' + } + } + + const debouncedRender = debounce((): void => { + void renderPreview() + }, DEBOUNCE_MS) + + watch( + shouldRender, + (active) => { + if (isGLSLNode.value) { + const node = nodeRef.value + if (node) node.hideOutputImages = active + } + if (active) debouncedRender() + }, + { immediate: true } + ) + + watch( + () => + [ + floatValues.value, + intValues.value, + boolValues.value, + curveValues.value + ] as const, + () => { + if (shouldRender.value) debouncedRender() + }, + { deep: true } + ) + + watch(shaderSource, () => { + if (shouldRender.value) debouncedRender() + }) + + // Return dispose function for the inner tier + return () => { + disposed = true + debouncedRender.cancel() + renderer?.dispose() + renderer = null + + // Revoke preview blob URLs to avoid memory leaks + const inner = innerGLSLNode + if (inner) { + const locatorId = nodeToNodeLocatorId(inner) + nodeOutputStore.revokePreviewsByLocatorId(locatorId) + } else { + const nId = nodeId.value + if (nId != null) { + const locatorId = nodeToNodeLocatorId(nodeRef.value!) + nodeOutputStore.revokePreviewsByLocatorId(locatorId) + } + } + } +} diff --git a/src/renderer/glsl/useGLSLRenderer.test.ts b/src/renderer/glsl/useGLSLRenderer.test.ts new file mode 100644 index 0000000000..077061f216 --- /dev/null +++ b/src/renderer/glsl/useGLSLRenderer.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer' + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + onScopeDispose: vi.fn() + } +}) + +describe('useGLSLRenderer', () => { + it('returns renderer API with expected methods', async () => { + const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer') + const renderer = useGLSLRenderer() + + expect(renderer).toHaveProperty('init') + expect(renderer).toHaveProperty('compileFragment') + expect(renderer).toHaveProperty('setResolution') + expect(renderer).toHaveProperty('setFloatUniform') + expect(renderer).toHaveProperty('setIntUniform') + expect(renderer).toHaveProperty('bindInputImage') + expect(renderer).toHaveProperty('render') + expect(renderer).toHaveProperty('readPixels') + expect(renderer).toHaveProperty('toBlob') + expect(renderer).toHaveProperty('dispose') + }) + + it('init returns false when WebGL2 is unavailable', async () => { + const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer') + const renderer = useGLSLRenderer() + expect(renderer.init(256, 256)).toBe(false) + }) + + it('compileFragment reports error before initialization', async () => { + const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer') + const renderer = useGLSLRenderer() + const result = renderer.compileFragment('void main() {}') + expect(result.success).toBe(false) + }) + + it('toBlob rejects before initialization', async () => { + const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer') + const renderer = useGLSLRenderer() + await expect(renderer.toBlob()).rejects.toThrow('Renderer not initialized') + }) + + it('accepts custom config without error', async () => { + const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer') + const config: GLSLRendererConfig = { + maxInputs: 3, + maxFloatUniforms: 2, + maxIntUniforms: 1, + maxBoolUniforms: 1, + maxCurves: 2 + } + const renderer = useGLSLRenderer(config) + expect(renderer.init(256, 256)).toBe(false) + }) +}) diff --git a/src/renderer/glsl/useGLSLRenderer.ts b/src/renderer/glsl/useGLSLRenderer.ts index d6cb02698f..aef7321512 100644 --- a/src/renderer/glsl/useGLSLRenderer.ts +++ b/src/renderer/glsl/useGLSLRenderer.ts @@ -1,5 +1,3 @@ -import { onScopeDispose } from 'vue' - import { detectPassCount } from '@/renderer/glsl/glslUtils' const VERTEX_SHADER_SOURCE = `#version 300 es @@ -17,12 +15,16 @@ export interface GLSLRendererConfig { maxInputs: number maxFloatUniforms: number maxIntUniforms: number + maxBoolUniforms: number + maxCurves: number } const DEFAULT_CONFIG: GLSLRendererConfig = { maxInputs: 5, - maxFloatUniforms: 5, - maxIntUniforms: 5 + maxFloatUniforms: 20, + maxIntUniforms: 20, + maxBoolUniforms: 10, + maxCurves: 4 } interface CompileResult { @@ -50,15 +52,22 @@ function compileShader( } export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { - const { maxInputs, maxFloatUniforms, maxIntUniforms } = config + const { + maxInputs, + maxFloatUniforms, + maxIntUniforms, + maxBoolUniforms, + maxCurves + } = config const uniformNames = [ 'u_resolution', 'u_pass', - 'u_prevPass', ...Array.from({ length: maxInputs }, (_, i) => `u_image${i}`), ...Array.from({ length: maxFloatUniforms }, (_, i) => `u_float${i}`), - ...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`) + ...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`), + ...Array.from({ length: maxBoolUniforms }, (_, i) => `u_bool${i}`), + ...Array.from({ length: maxCurves }, (_, i) => `u_curve${i}`) ] let canvas: OffscreenCanvas | null = null @@ -72,9 +81,13 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { const inputTextures: (WebGLTexture | null)[] = Array.from({ length: maxInputs }).fill(null) + const curveTextures: (WebGLTexture | null)[] = Array.from({ + length: maxCurves + }).fill(null) const uniformLocations = new Map() let passCount = 1 let disposed = false + let lastCompiledSource: string | null = null function initPingPongFBOs( ctx: WebGL2RenderingContext, @@ -92,12 +105,12 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { ctx.texImage2D( ctx.TEXTURE_2D, 0, - ctx.RGBA8, + ctx.RGBA16F, width, height, 0, ctx.RGBA, - ctx.UNSIGNED_BYTE, + ctx.HALF_FLOAT, null ) ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_MIN_FILTER, ctx.LINEAR) @@ -191,6 +204,9 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { if (!ctx) return false gl = ctx + + if (!gl.getExtension('EXT_color_buffer_float')) return false + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE) initPingPongFBOs(gl, width, height) @@ -206,6 +222,11 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { passCount = Math.min(detectPassCount(source), MAX_PASSES) + if (source === lastCompiledSource && program) { + return { success: true, log: '' } + } + lastCompiledSource = source + if (fragmentShader) { gl.deleteShader(fragmentShader) fragmentShader = null @@ -270,6 +291,51 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { } } + function setBoolUniform(index: number, value: boolean): void { + if (disposed || !program || !gl) return + const loc = uniformLocations.get(`u_bool${index}`) + if (loc != null) { + gl.useProgram(program) + gl.uniform1i(loc, value ? 1 : 0) + } + } + + function bindCurveTexture(index: number, lut: Float32Array): void { + if (disposed || !gl) return + if (index < 0 || index >= maxCurves) return + + if (curveTextures[index]) { + gl.deleteTexture(curveTextures[index]) + curveTextures[index] = null + } + + const texture = gl.createTexture() + if (!texture) return + + const unit = maxInputs + index + gl.activeTexture(gl.TEXTURE0 + unit) + gl.bindTexture(gl.TEXTURE_2D, texture) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false) + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16F, + lut.length, + 1, + 0, + gl.RED, + gl.FLOAT, + lut + ) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + + curveTextures[index] = texture + } + function bindInputImage( index: number, image: HTMLImageElement | ImageBitmap @@ -304,6 +370,7 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { if (disposed || !program || !pingPongFBOs || !gl || !canvas) return gl.useProgram(program) + gl.disable(gl.BLEND) const resLoc = uniformLocations.get('u_resolution') if (resLoc != null) { @@ -319,8 +386,15 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { } } - const prevPassUnit = maxInputs - const prevPassLoc = uniformLocations.get('u_prevPass') + for (let i = 0; i < maxCurves; i++) { + const loc = uniformLocations.get(`u_curve${i}`) + if (loc != null && curveTextures[i]) { + const unit = maxInputs + i + gl.activeTexture(gl.TEXTURE0 + unit) + gl.bindTexture(gl.TEXTURE_2D, curveTextures[i]) + gl.uniform1i(loc, unit) + } + } for (let pass = 0; pass < passCount; pass++) { const passLoc = uniformLocations.get('u_pass') @@ -328,31 +402,26 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { const isLastPass = pass === passCount - 1 const writeIdx = pass % 2 - const readIdx = 1 - writeIdx if (isLastPass) { gl.bindFramebuffer(gl.FRAMEBUFFER, null) - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFBOs[writeIdx]) - } - - // Note: u_prevPass uses ping-pong FBOs rather than overwriting the input - // texture in-place as the backend does for single-input iteration. - if (pass > 0 && prevPassLoc != null) { - gl.activeTexture(gl.TEXTURE0 + prevPassUnit) - gl.bindTexture(gl.TEXTURE_2D, pingPongTextures![readIdx]) - gl.uniform1i(prevPassLoc, prevPassUnit) - } - - // Ping-pong FBOs have a single color attachment, so intermediate - // passes always target COLOR_ATTACHMENT0. MRT is only possible on - // the default framebuffer (last pass). - if (isLastPass) { gl.drawBuffers([gl.BACK]) } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFBOs[writeIdx]) gl.drawBuffers([gl.COLOR_ATTACHMENT0]) } + // Match backend behavior: pass > 0 binds previous pass output to + // texture unit 0, overriding u_image0 so shaders read the previous + // pass result via the same sampler. + if (pass > 0) { + const sourceTexture = pingPongTextures![(pass - 1) % 2] + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, sourceTexture) + } + + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) gl.drawArrays(gl.TRIANGLES, 0, 3) } } @@ -371,7 +440,7 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { async function toBlob(): Promise { if (!canvas) throw new Error('Renderer not initialized') - return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 }) + return canvas.convertToBlob({ type: 'image/webp', quality: 0.92 }) } function dispose(): void { @@ -384,6 +453,11 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { } inputTextures.fill(null) + for (const tex of curveTextures) { + if (tex) gl.deleteTexture(tex) + } + curveTextures.fill(null) + if (fallbackTexture) { gl.deleteTexture(fallbackTexture) fallbackTexture = null @@ -411,14 +485,14 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { ext?.loseContext() } - onScopeDispose(dispose) - return { init, compileFragment, setResolution, setFloatUniform, setIntUniform, + setBoolUniform, + bindCurveTexture, bindInputImage, render, readPixels, diff --git a/src/renderer/glsl/useGLSLUniforms.ts b/src/renderer/glsl/useGLSLUniforms.ts new file mode 100644 index 0000000000..92b7ae9aa1 --- /dev/null +++ b/src/renderer/glsl/useGLSLUniforms.ts @@ -0,0 +1,247 @@ +import { computed } from 'vue' + +import type { ComputedRef } from 'vue' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants' +import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph' +import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import { useWidgetValueStore } from '@/stores/widgetValueStore' + +import { isCurveData } from '@/components/curve/curveUtils' +import type { CurveData } from '@/components/curve/types' +import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer' + +interface AutogrowGroup { + max: number + min: number + prefix?: string +} + +export interface UniformSource { + nodeId: NodeId + widgetName: string +} + +export interface UniformSources { + floats: UniformSource[] + ints: UniformSource[] + bools: UniformSource[] + curves: UniformSource[] +} + +export function getAutogrowLimits(node: LGraphNode): GLSLRendererConfig { + const defaults: GLSLRendererConfig = { + maxInputs: 5, + maxFloatUniforms: 20, + maxIntUniforms: 20, + maxBoolUniforms: 10, + maxCurves: 4 + } + + if (!('comfyDynamic' in node)) return defaults + + const dynamic = node.comfyDynamic + if ( + typeof dynamic !== 'object' || + dynamic === null || + !('autogrow' in dynamic) + ) + return defaults + + const groups = dynamic.autogrow as Record | undefined + if (!groups) return defaults + + return { + maxInputs: groups['images']?.max ?? defaults.maxInputs, + maxFloatUniforms: groups['floats']?.max ?? defaults.maxFloatUniforms, + maxIntUniforms: groups['ints']?.max ?? defaults.maxIntUniforms, + maxBoolUniforms: groups['bools']?.max ?? defaults.maxBoolUniforms, + maxCurves: groups['curves']?.max ?? defaults.maxCurves + } +} + +export function extractUniformSources( + glslNode: LGraphNode, + subgraph: Subgraph +): UniformSources { + const floats: UniformSource[] = [] + const ints: UniformSource[] = [] + const bools: UniformSource[] = [] + const curves: UniformSource[] = [] + + if (!glslNode.inputs) return { floats, ints, bools, curves } + + for (const input of glslNode.inputs) { + if (input.link == null) continue + + const link = subgraph.getLink(input.link) + if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue + + const sourceNode = subgraph.getNodeById(link.origin_id) + if (!sourceNode?.widgets?.[0]) continue + + const inputName = input.name ?? '' + const dotIndex = inputName.indexOf('.') + if (dotIndex === -1) continue + + const prefix = inputName.slice(0, dotIndex) + const source: UniformSource = { + nodeId: sourceNode.id as NodeId, + widgetName: sourceNode.widgets[0].name + } + + if (prefix === 'floats') floats.push(source) + else if (prefix === 'ints') ints.push(source) + else if (prefix === 'bools') bools.push(source) + else if (prefix === 'curves') curves.push(source) + } + + return { floats, ints, bools, curves } +} + +export function useGLSLUniforms( + graphId: ComputedRef, + nodeId: ComputedRef, + nodeRef: ComputedRef, + uniformSources: ComputedRef, + rendererConfig: ComputedRef +) { + const widgetValueStore = useWidgetValueStore() + + function collectValues( + subgraphSources: UniformSource[] | undefined, + groupName: string, + uniformPrefix: string, + maxCount: number, + coerce: (value: unknown) => T, + defaultValue: T + ): T[] { + const gId = graphId.value + if (!gId) return [] + + if (subgraphSources) { + return subgraphSources.map(({ nodeId: nId, widgetName }) => { + const widget = widgetValueStore.getWidget(gId, nId, widgetName) + return coerce(widget?.value ?? defaultValue) + }) + } + + const nId = nodeId.value + const node = nodeRef.value + if (nId == null || !node) return [] + + const values: T[] = [] + for (let i = 0; i < maxCount; i++) { + const inputName = `${groupName}.${uniformPrefix}${i}` + const widget = widgetValueStore.getWidget(gId, nId, inputName) + if (widget !== undefined) { + values.push(coerce(widget.value)) + continue + } + + const slot = node.inputs?.findIndex((inp) => inp.name === inputName) + if (slot == null || slot < 0) break + + const upstreamNode = node.getInputNode(slot) + if (!upstreamNode) break + const upstreamWidgets = widgetValueStore.getNodeWidgets( + gId, + upstreamNode.id as NodeId + ) + if (upstreamWidgets.length === 0) break + values.push(coerce(upstreamWidgets[0].value)) + } + return values + } + + const toNumber = (v: unknown): number => Number(v) || 0 + const toBool = (v: unknown): boolean => Boolean(v) + + const floatValues = computed(() => + collectValues( + uniformSources.value?.floats, + 'floats', + 'u_float', + rendererConfig.value.maxFloatUniforms, + toNumber, + 0 + ) + ) + + const intValues = computed(() => + collectValues( + uniformSources.value?.ints, + 'ints', + 'u_int', + rendererConfig.value.maxIntUniforms, + toNumber, + 0 + ) + ) + + const boolValues = computed(() => + collectValues( + uniformSources.value?.bools, + 'bools', + 'u_bool', + rendererConfig.value.maxBoolUniforms, + toBool, + false + ) + ) + + const curveValues = computed((): CurveData[] => { + const gId = graphId.value + if (!gId) return [] + + const sources = uniformSources.value?.curves + if (sources && sources.length > 0) { + return sources + .map(({ nodeId: nId, widgetName }) => { + const widget = widgetValueStore.getWidget(gId, nId, widgetName) + return widget && isCurveData(widget.value) + ? (widget.value as CurveData) + : null + }) + .filter((v): v is CurveData => v !== null) + } + + const node = nodeRef.value + const nId = nodeId.value + if (nId == null || !node?.inputs) return [] + + const values: CurveData[] = [] + const max = rendererConfig.value.maxCurves + for (let i = 0; i < max; i++) { + const inputName = `curves.u_curve${i}` + + const widget = widgetValueStore.getWidget(gId, nId, inputName) + if (widget && isCurveData(widget.value)) { + values.push(widget.value as CurveData) + continue + } + + const slot = node.inputs.findIndex((inp) => inp.name === inputName) + if (slot < 0) break + + const upstreamNode = node.getInputNode(slot) + if (!upstreamNode) break + + const upstreamWidgets = widgetValueStore.getNodeWidgets( + gId, + upstreamNode.id as NodeId + ) + const curveWidget = upstreamWidgets.find((w) => isCurveData(w.value)) + if (!curveWidget) break + values.push(curveWidget.value as CurveData) + } + return values + }) + + return { + floatValues, + intValues, + boolValues, + curveValues + } +} diff --git a/src/stores/nodeOutputStore.ts b/src/stores/nodeOutputStore.ts index 51e0105d4d..74cdcdedeb 100644 --- a/src/stores/nodeOutputStore.ts +++ b/src/stores/nodeOutputStore.ts @@ -261,6 +261,17 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { ) { const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId) if (!nodeLocatorId) return + setNodePreviewsByLocatorId(nodeLocatorId, previewImages) + latestPreview.value = previewImages + } + + /** + * Set node preview images by NodeLocatorId directly. + */ + function setNodePreviewsByLocatorId( + nodeLocatorId: NodeLocatorId, + previewImages: string[] + ) { const existingPreviews = app.nodePreviewImages[nodeLocatorId] if (scheduledRevoke[nodeLocatorId]) { scheduledRevoke[nodeLocatorId].stop() @@ -274,7 +285,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { for (const url of previewImages) { retainSharedObjectUrl(url) } - latestPreview.value = previewImages app.nodePreviewImages[nodeLocatorId] = previewImages nodePreviewImages.value[nodeLocatorId] = previewImages } @@ -290,22 +300,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { nodeId: string | number, previewImages: string[] ) { - const nodeLocatorId = nodeIdToNodeLocatorId(nodeId) - const existingPreviews = app.nodePreviewImages[nodeLocatorId] - if (scheduledRevoke[nodeLocatorId]) { - scheduledRevoke[nodeLocatorId].stop() - delete scheduledRevoke[nodeLocatorId] - } - if (existingPreviews?.[Symbol.iterator]) { - for (const url of existingPreviews) { - releaseSharedObjectUrl(url) - } - } - for (const url of previewImages) { - retainSharedObjectUrl(url) - } - app.nodePreviewImages[nodeLocatorId] = previewImages - nodePreviewImages.value[nodeLocatorId] = previewImages + setNodePreviewsByLocatorId(nodeIdToNodeLocatorId(nodeId), previewImages) } /** @@ -486,6 +481,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { setNodeOutputs, setNodeOutputsByExecutionId, setNodePreviewsByExecutionId, + setNodePreviewsByLocatorId, setNodePreviewsByNodeId, updateNodeImages, refreshNodeOutputs, @@ -493,6 +489,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { // Cleanup revokePreviewsByExecutionId, + revokePreviewsByLocatorId, revokeAllPreviews, revokeSubgraphPreviews, removeNodeOutputs, From 3ac08fd1daca2ae88995d755ca95e8a75858c729 Mon Sep 17 00:00:00 2001 From: Dante Date: Mon, 30 Mar 2026 21:15:14 +0900 Subject: [PATCH 005/218] test(assets-sidebar): add comprehensive E2E tests for Assets browser panel (#10616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Extend `AssetsSidebarTab` page object with selectors for search, view mode, asset cards, selection footer, context menu, and folder view navigation - Add mock data factories (`createMockJob`, `createMockJobs`, `createMockImportedFiles`) to `AssetsHelper` for generating realistic test fixtures - Write 30 E2E test cases across 10 categories covering the Assets browser sidebar panel ## Test coverage added | Category | Tests | Details | |----------|-------|---------| | Empty states | 3 | Generated/Imported empty copy, zero cards | | Tab navigation | 3 | Default tab, switching, search reset on tab change | | Grid view display | 2 | Generated card rendering, Imported tab assets | | View mode toggle | 2 | Grid↔List switching via settings menu | | Search | 4 | Input visibility, filtering, clearing, no-match state | | Selection | 5 | Click select, Ctrl+click multi, footer, deselect all, tab-switch clear | | Context menu | 7 | Right-click menu, Download/Inspect/Delete/CopyJobID/Workflow actions, bulk menu | | Bulk actions | 3 | Download/Delete buttons, selection count display | | Pagination | 1 | Large job set initial load | | Settings menu | 1 | View mode options visibility | ## Context Part of [FixIt Burndown](https://www.notion.so/comfy-org/FixIt-Burndown-32e6d73d365080609a81cdc9bc884460) — "Untested Side Panels: Assets browser" assigned to @dante01yoon. ## Test plan - [ ] Run `npx playwright test browser_tests/tests/sidebar/assets.spec.ts` against local ComfyUI backend - [ ] Verify all 30 tests pass - [ ] CI green ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10616-test-assets-sidebar-add-comprehensive-E2E-tests-for-Assets-browser-panel-3306d73d365081eeb237e559f56689bf) by [Unito](https://www.unito.io) --- .../fixtures/components/SidebarTab.ts | 166 +++++ .../fixtures/helpers/AssetsHelper.ts | 57 ++ browser_tests/tests/sidebar/assets.spec.ts | 644 +++++++++++++++++- .../sidebar/tabs/AssetsSidebarTab.vue | 14 +- 4 files changed, 873 insertions(+), 8 deletions(-) diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts index 119c41ed58..2bc4ee8ac8 100644 --- a/browser_tests/fixtures/components/SidebarTab.ts +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -1,4 +1,5 @@ import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' import type { WorkspaceStore } from '../../types/globals' import { TestIds } from '../selectors' @@ -174,6 +175,8 @@ export class AssetsSidebarTab extends SidebarTab { super(page, 'assets') } + // --- Tab navigation --- + get generatedTab() { return this.page.getByRole('tab', { name: 'Generated' }) } @@ -182,6 +185,8 @@ export class AssetsSidebarTab extends SidebarTab { return this.page.getByRole('tab', { name: 'Imported' }) } + // --- Empty state --- + get emptyStateMessage() { return this.page.getByText( 'Upload files or generate content to see them here' @@ -192,8 +197,169 @@ export class AssetsSidebarTab extends SidebarTab { return this.page.getByText(title) } + // --- Search & filter --- + + get searchInput() { + return this.page.getByPlaceholder('Search Assets...') + } + + get settingsButton() { + return this.page.getByRole('button', { name: 'View settings' }) + } + + // --- View mode --- + + get listViewOption() { + return this.page.getByText('List view') + } + + get gridViewOption() { + return this.page.getByText('Grid view') + } + + // --- Sort options (cloud-only, shown inside settings popover) --- + + get sortNewestFirst() { + return this.page.getByText('Newest first') + } + + get sortOldestFirst() { + return this.page.getByText('Oldest first') + } + + // --- Asset cards --- + + get assetCards() { + return this.page.locator('[role="button"][data-selected]') + } + + getAssetCardByName(name: string) { + return this.page.locator('[role="button"][data-selected]', { + hasText: name + }) + } + + get selectedCards() { + return this.page.locator('[data-selected="true"]') + } + + // --- List view items --- + + get listViewItems() { + return this.page.locator( + '.sidebar-content-container [role="button"][tabindex="0"]' + ) + } + + // --- Selection footer --- + + get selectionFooter() { + return this.page + .locator('.sidebar-content-container') + .locator('..') + .locator('[class*="h-18"]') + } + + get selectionCountButton() { + return this.page.getByText(/Assets Selected: \d+/) + } + + get deselectAllButton() { + return this.page.getByText('Deselect all') + } + + get deleteSelectedButton() { + return this.page + .getByTestId('assets-delete-selected') + .or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last()) + .first() + } + + get downloadSelectedButton() { + return this.page + .getByTestId('assets-download-selected') + .or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last()) + .first() + } + + // --- Context menu --- + + contextMenuItem(label: string) { + return this.page.locator('.p-contextmenu').getByText(label) + } + + // --- Folder view --- + + get backToAssetsButton() { + return this.page.getByText('Back to all assets') + } + + // --- Loading --- + + get skeletonLoaders() { + return this.page.locator('.sidebar-content-container .animate-pulse') + } + + // --- Helpers --- + override async open() { + // Remove any toast notifications that may overlay the sidebar button + await this.dismissToasts() await super.open() await this.generatedTab.waitFor({ state: 'visible' }) } + + /** Dismiss all visible toast notifications by clicking their close buttons. */ + async dismissToasts() { + const closeButtons = this.page.locator('.p-toast-close-button') + for (const btn of await closeButtons.all()) { + await btn.click({ force: true }).catch(() => {}) + } + // Wait for all toast elements to fully animate out and detach from DOM + await expect(this.page.locator('.p-toast-message')) + .toHaveCount(0, { timeout: 5000 }) + .catch(() => {}) + } + + async switchToImported() { + await this.dismissToasts() + await this.importedTab.click() + await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', { + timeout: 3000 + }) + } + + async switchToGenerated() { + await this.dismissToasts() + await this.generatedTab.click() + await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', { + timeout: 3000 + }) + } + + async openSettingsMenu() { + await this.dismissToasts() + await this.settingsButton.click() + // Wait for popover content to render + await this.listViewOption + .or(this.gridViewOption) + .first() + .waitFor({ state: 'visible', timeout: 3000 }) + } + + async rightClickAsset(name: string) { + const card = this.getAssetCardByName(name) + await card.click({ button: 'right' }) + await this.page + .locator('.p-contextmenu') + .waitFor({ state: 'visible', timeout: 3000 }) + } + + async waitForAssets(count?: number) { + if (count !== undefined) { + await expect(this.assetCards).toHaveCount(count, { timeout: 5000 }) + } else { + await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 }) + } + } } diff --git a/browser_tests/fixtures/helpers/AssetsHelper.ts b/browser_tests/fixtures/helpers/AssetsHelper.ts index a9d8e69a6e..82ea91cce6 100644 --- a/browser_tests/fixtures/helpers/AssetsHelper.ts +++ b/browser_tests/fixtures/helpers/AssetsHelper.ts @@ -5,6 +5,63 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/ const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/ +/** Factory to create a mock completed job with preview output. */ +export function createMockJob( + overrides: Partial & { id: string } +): RawJobListItem { + const now = Date.now() / 1000 + return { + status: 'completed', + create_time: now, + execution_start_time: now, + execution_end_time: now + 5, + preview_output: { + filename: `output_${overrides.id}.png`, + subfolder: '', + type: 'output', + nodeId: '1', + mediaType: 'images' + }, + outputs_count: 1, + priority: 0, + ...overrides + } +} + +/** Create multiple mock jobs with sequential IDs and staggered timestamps. */ +export function createMockJobs( + count: number, + baseOverrides?: Partial +): RawJobListItem[] { + const now = Date.now() / 1000 + return Array.from({ length: count }, (_, i) => + createMockJob({ + id: `job-${String(i + 1).padStart(3, '0')}`, + create_time: now - i * 60, + execution_start_time: now - i * 60, + execution_end_time: now - i * 60 + 5 + i, + preview_output: { + filename: `image_${String(i + 1).padStart(3, '0')}.png`, + subfolder: '', + type: 'output', + nodeId: '1', + mediaType: 'images' + }, + ...baseOverrides + }) + ) +} + +/** Create mock imported file names with various media types. */ +export function createMockImportedFiles(count: number): string[] { + const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt'] + return Array.from( + { length: count }, + (_, i) => + `imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}` + ) +} + function parseLimit(url: URL, total: number): number { const value = Number(url.searchParams.get('limit')) if (!Number.isInteger(value) || value <= 0) { diff --git a/browser_tests/tests/sidebar/assets.spec.ts b/browser_tests/tests/sidebar/assets.spec.ts index 5f7653ec8a..6dce1cdab0 100644 --- a/browser_tests/tests/sidebar/assets.spec.ts +++ b/browser_tests/tests/sidebar/assets.spec.ts @@ -1,8 +1,72 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../../fixtures/ComfyPage' +import { + createMockJob, + createMockJobs +} from '../../fixtures/helpers/AssetsHelper' +import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes' -test.describe('Assets sidebar', () => { +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const SAMPLE_JOBS: RawJobListItem[] = [ + createMockJob({ + id: 'job-alpha', + create_time: 1000, + execution_start_time: 1000, + execution_end_time: 1010, + preview_output: { + filename: 'landscape.png', + subfolder: '', + type: 'output', + nodeId: '1', + mediaType: 'images' + }, + outputs_count: 1 + }), + createMockJob({ + id: 'job-beta', + create_time: 2000, + execution_start_time: 2000, + execution_end_time: 2003, + preview_output: { + filename: 'portrait.png', + subfolder: '', + type: 'output', + nodeId: '2', + mediaType: 'images' + }, + outputs_count: 1 + }), + createMockJob({ + id: 'job-gamma', + create_time: 3000, + execution_start_time: 3000, + execution_end_time: 3020, + preview_output: { + filename: 'abstract_art.png', + subfolder: '', + type: 'output', + nodeId: '3', + mediaType: 'images' + }, + outputs_count: 2 + }) +] + +const SAMPLE_IMPORTED_FILES = [ + 'reference_photo.png', + 'background.jpg', + 'audio_clip.wav' +] + +// ========================================================================== +// 1. Empty states +// ========================================================================== + +test.describe('Assets sidebar - empty states', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.assets.mockEmptyState() await comfyPage.setup() @@ -12,19 +76,587 @@ test.describe('Assets sidebar', () => { await comfyPage.assets.clearMocks() }) - test('Shows empty-state copy for generated and imported tabs', async ({ - comfyPage - }) => { + test('Shows empty-state copy for generated tab', async ({ comfyPage }) => { const tab = comfyPage.menu.assetsTab - await tab.open() await expect(tab.emptyStateTitle('No generated files found')).toBeVisible() await expect(tab.emptyStateMessage).toBeVisible() + }) - await tab.importedTab.click() + test('Shows empty-state copy for imported tab', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.switchToImported() await expect(tab.emptyStateTitle('No imported files found')).toBeVisible() await expect(tab.emptyStateMessage).toBeVisible() }) + + test('No asset cards are rendered when empty', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + await expect(tab.assetCards).toHaveCount(0) + }) +}) + +// ========================================================================== +// 2. Tab navigation +// ========================================================================== + +test.describe('Assets sidebar - tab navigation', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Generated tab is active by default', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true') + await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false') + }) + + test('Can switch between Generated and Imported tabs', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + // Switch to Imported + await tab.switchToImported() + await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true') + await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false') + + // Switch back to Generated + await tab.switchToGenerated() + await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true') + }) + + test('Search is cleared when switching tabs', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + // Type search in Generated tab + await tab.searchInput.fill('landscape') + await expect(tab.searchInput).toHaveValue('landscape') + + // Switch to Imported tab + await tab.switchToImported() + await expect(tab.searchInput).toHaveValue('') + }) +}) + +// ========================================================================== +// 3. Asset display - grid view +// ========================================================================== + +test.describe('Assets sidebar - grid view display', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Displays generated assets as cards in grid view', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + await tab.waitForAssets() + const count = await tab.assetCards.count() + expect(count).toBeGreaterThanOrEqual(1) + }) + + test('Displays imported files when switching to Imported tab', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.switchToImported() + + // Wait for imported assets to render + await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 }) + + // Imported tab should show the mocked files + const count = await tab.assetCards.count() + expect(count).toBeGreaterThanOrEqual(1) + }) +}) + +// ========================================================================== +// 4. View mode toggle (grid <-> list) +// ========================================================================== + +test.describe('Assets sidebar - view mode toggle', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles([]) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Can switch to list view via settings menu', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Open settings menu and select list view + await tab.openSettingsMenu() + await tab.listViewOption.click() + + // List view items should now be visible + await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 }) + }) + + test('Can switch back to grid view', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Switch to list view + await tab.openSettingsMenu() + await tab.listViewOption.click() + await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 }) + + // Switch back to grid view (settings popover is still open) + await tab.gridViewOption.click() + await tab.waitForAssets() + + // Grid cards (with data-selected attribute) should be visible again + await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 }) + }) +}) + +// ========================================================================== +// 5. Search functionality +// ========================================================================== + +test.describe('Assets sidebar - search', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles([]) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Search input is visible', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + await expect(tab.searchInput).toBeVisible() + }) + + test('Filtering assets by search query reduces displayed count', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + const initialCount = await tab.assetCards.count() + + // Search for a specific filename that matches only one asset + await tab.searchInput.fill('landscape') + + // Wait for filter to reduce the count + await expect(async () => { + const filteredCount = await tab.assetCards.count() + expect(filteredCount).toBeLessThan(initialCount) + }).toPass({ timeout: 5000 }) + }) + + test('Clearing search restores all assets', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + const initialCount = await tab.assetCards.count() + + // Filter then clear + await tab.searchInput.fill('landscape') + await expect(async () => { + expect(await tab.assetCards.count()).toBeLessThan(initialCount) + }).toPass({ timeout: 5000 }) + + await tab.searchInput.fill('') + await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 }) + }) + + test('Search with no matches shows empty state', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.searchInput.fill('nonexistent_file_xyz') + await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 }) + }) +}) + +// ========================================================================== +// 6. Asset selection +// ========================================================================== + +test.describe('Assets sidebar - selection', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles([]) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Clicking an asset card selects it', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Click first asset card + await tab.assetCards.first().click() + + // Should have data-selected="true" + await expect(tab.selectedCards).toHaveCount(1) + }) + + test('Ctrl+click adds to selection', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + const cards = tab.assetCards + const cardCount = await cards.count() + expect(cardCount).toBeGreaterThanOrEqual(2) + + // Click first card + await cards.first().click() + await expect(tab.selectedCards).toHaveCount(1) + + // Ctrl+click second card + await cards.nth(1).click({ modifiers: ['ControlOrMeta'] }) + await expect(tab.selectedCards).toHaveCount(2) + }) + + test('Selection shows footer with count and actions', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Select an asset + await tab.assetCards.first().click() + + // Footer should show selection count + await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 }) + }) + + test('Deselect all clears selection', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Select an asset + await tab.assetCards.first().click() + await expect(tab.selectedCards).toHaveCount(1) + + // Hover over the selection count button to reveal "Deselect all" + await tab.selectionCountButton.hover() + await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 }) + + // Click "Deselect all" + await tab.deselectAllButton.click() + await expect(tab.selectedCards).toHaveCount(0) + }) + + test('Selection is cleared when switching tabs', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Select an asset + await tab.assetCards.first().click() + await expect(tab.selectedCards).toHaveCount(1) + + // Switch to Imported tab + await tab.switchToImported() + + // Switch back - selection should be cleared + await tab.switchToGenerated() + await tab.waitForAssets() + await expect(tab.selectedCards).toHaveCount(0) + }) +}) + +// ========================================================================== +// 7. Context menu +// ========================================================================== + +test.describe('Assets sidebar - context menu', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles([]) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Right-clicking an asset shows context menu', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Right-click first asset + await tab.assetCards.first().click({ button: 'right' }) + + // Context menu should appear with standard items + const contextMenu = comfyPage.page.locator('.p-contextmenu') + await expect(contextMenu).toBeVisible({ timeout: 3000 }) + }) + + test('Context menu contains Download action for output asset', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click({ button: 'right' }) + await comfyPage.page + .locator('.p-contextmenu') + .waitFor({ state: 'visible', timeout: 3000 }) + + await expect(tab.contextMenuItem('Download')).toBeVisible() + }) + + test('Context menu contains Inspect action for image assets', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click({ button: 'right' }) + await comfyPage.page + .locator('.p-contextmenu') + .waitFor({ state: 'visible', timeout: 3000 }) + + await expect(tab.contextMenuItem('Inspect asset')).toBeVisible() + }) + + test('Context menu contains Delete action for output assets', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click({ button: 'right' }) + await comfyPage.page + .locator('.p-contextmenu') + .waitFor({ state: 'visible', timeout: 3000 }) + + await expect(tab.contextMenuItem('Delete')).toBeVisible() + }) + + test('Context menu contains Copy job ID for output assets', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click({ button: 'right' }) + await comfyPage.page + .locator('.p-contextmenu') + .waitFor({ state: 'visible', timeout: 3000 }) + + await expect(tab.contextMenuItem('Copy job ID')).toBeVisible() + }) + + test('Context menu contains workflow actions for output assets', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click({ button: 'right' }) + + const contextMenu = comfyPage.page.locator('.p-contextmenu') + await expect(contextMenu).toBeVisible({ timeout: 3000 }) + + await expect( + tab.contextMenuItem('Open as workflow in new tab') + ).toBeVisible() + await expect(tab.contextMenuItem('Export workflow')).toBeVisible() + }) + + test('Bulk context menu shows when multiple assets selected', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + const cards = tab.assetCards + const cardCount = await cards.count() + expect(cardCount).toBeGreaterThanOrEqual(2) + + // Dismiss any toasts that appeared after asset loading + await tab.dismissToasts() + + // Multi-select: click first, then Ctrl/Cmd+click second + await cards.first().click() + await cards.nth(1).click({ modifiers: ['ControlOrMeta'] }) + + // Verify multi-selection took effect and footer is stable before right-clicking + await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 }) + await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 }) + + // Right-click on a selected card (retry to let grid layout settle) + const contextMenu = comfyPage.page.locator('.p-contextmenu') + await expect(async () => { + await cards.first().click({ button: 'right' }) + await expect(contextMenu).toBeVisible() + }).toPass({ intervals: [300], timeout: 5000 }) + + // Bulk menu should show bulk download action + await expect(tab.contextMenuItem('Download all')).toBeVisible() + }) +}) + +// ========================================================================== +// 8. Bulk actions (footer) +// ========================================================================== + +test.describe('Assets sidebar - bulk actions', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles([]) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Footer shows download button when assets selected', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click() + + // Download button in footer should be visible + await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 }) + }) + + test('Footer shows delete button when output assets selected', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click() + + // Delete button in footer should be visible + await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 }) + }) + + test('Selection count displays correct number', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Select two assets + const cards = tab.assetCards + const cardCount = await cards.count() + expect(cardCount).toBeGreaterThanOrEqual(2) + + await cards.first().click() + await cards.nth(1).click({ modifiers: ['ControlOrMeta'] }) + + // Selection count should show the count + await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 }) + const text = await tab.selectionCountButton.textContent() + expect(text).toMatch(/Assets Selected: \d+/) + }) +}) + +// ========================================================================== +// 9. Pagination +// ========================================================================== + +test.describe('Assets sidebar - pagination', () => { + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Initially loads a batch of assets with has_more pagination', async ({ + comfyPage + }) => { + // Create a large set of jobs to trigger pagination + const manyJobs = createMockJobs(30) + await comfyPage.assets.mockOutputHistory(manyJobs) + await comfyPage.setup() + + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + // Should load at least the first batch + const count = await tab.assetCards.count() + expect(count).toBeGreaterThanOrEqual(1) + }) +}) + +// ========================================================================== +// 10. Settings menu visibility +// ========================================================================== + +test.describe('Assets sidebar - settings menu', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockInputFiles([]) + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Settings menu shows view mode options', async ({ comfyPage }) => { + const tab = comfyPage.menu.assetsTab + await tab.open() + + await tab.openSettingsMenu() + + await expect(tab.listViewOption).toBeVisible() + await expect(tab.gridViewOption).toBeVisible() + }) }) diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index e91f9f6b63..4188e0cccb 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -143,11 +143,16 @@ - @@ -156,12 +161,17 @@ - From 61144ea1d58573a1b422958709baf66c686af1e2 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:31:51 +0100 Subject: [PATCH 006/218] test: add 23 E2E tests for Vue node context menu actions (#10603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add 23 Playwright E2E tests for all right-click context menu actions on Vue nodes - **Single node (7 tests)**: rename, copy/paste, duplicate, pin/unpin, bypass/remove bypass, minimize/expand, convert to subgraph - **Image node (4 tests)**: copy image to clipboard, paste image from clipboard, open image in new tab, download via save image - **Subgraph (3 tests)**: convert + unpack roundtrip, edit subgraph widgets opens properties panel, add to library and find in node library search - **Multi-node (9 tests)**: batch rename, copy/paste, duplicate, pin/unpin, bypass/remove bypass, minimize/expand, frame nodes, convert to group node, convert to subgraph - Uses `ControlOrMeta` modifier for multi-node selection ## Test plan - [x] All 23 tests pass locally (`pnpm test:browser:local`) - [x] TypeScript type check passes (`pnpm typecheck:browser`) - [x] ESLint passes - [x] CodeRabbit review: no findings ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10603-test-add-23-E2E-tests-for-Vue-node-context-menu-actions-3306d73d3650818a932fc62205ac6fa8) by [Unito](https://www.unito.io) --- .../interactions/node/contextMenu.spec.ts | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts diff --git a/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts b/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts new file mode 100644 index 0000000000..a7190e78ad --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts @@ -0,0 +1,528 @@ +import type { Locator } from '@playwright/test' + +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' +import type { ComfyPage } from '../../../../fixtures/ComfyPage' + +const BYPASS_CLASS = /before:bg-bypass\/60/ +const PIN_INDICATOR = '[data-testid="node-pin-indicator"]' + +async function clickExactMenuItem(comfyPage: ComfyPage, name: string) { + await comfyPage.page.getByRole('menuitem', { name, exact: true }).click() + await comfyPage.nextFrame() +} + +async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) { + const header = comfyPage.vueNodes + .getNodeByTitle(nodeTitle) + .locator('.lg-node-header') + await header.click() + await header.click({ button: 'right' }) + const menu = comfyPage.page.locator('.p-contextmenu') + await menu.waitFor({ state: 'visible' }) + return menu +} + +async function openMultiNodeContextMenu( + comfyPage: ComfyPage, + titles: string[] +) { + // deselectAll via evaluate — clearSelection() clicks at a fixed position + // which can hit nodes or the toolbar overlay + await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll()) + await comfyPage.nextFrame() + + for (const title of titles) { + const header = comfyPage.vueNodes + .getNodeByTitle(title) + .locator('.lg-node-header') + await header.click({ modifiers: ['ControlOrMeta'] }) + } + await comfyPage.nextFrame() + + const firstHeader = comfyPage.vueNodes + .getNodeByTitle(titles[0]) + .locator('.lg-node-header') + const box = await firstHeader.boundingBox() + if (!box) throw new Error(`Header for "${titles[0]}" not found`) + await comfyPage.page.mouse.click( + box.x + box.width / 2, + box.y + box.height / 2, + { button: 'right' } + ) + + const menu = comfyPage.page.locator('.p-contextmenu') + await menu.waitFor({ state: 'visible' }) + return menu +} + +function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator { + return comfyPage.page + .locator('[data-node-id]') + .filter({ hasText: nodeTitle }) + .getByTestId('node-inner-wrapper') +} + +async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) { + const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle) + return refs[0] +} + +test.describe('Vue Node Context Menu', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.vueNodes.waitForNodes() + }) + + test.describe('Single Node Actions', () => { + test('should rename node via context menu', async ({ comfyPage }) => { + await openContextMenu(comfyPage, 'KSampler') + await clickExactMenuItem(comfyPage, 'Rename') + + const titleInput = comfyPage.page.locator( + '.node-title-editor input[type="text"]' + ) + await titleInput.waitFor({ state: 'visible' }) + await titleInput.fill('My Renamed Sampler') + await titleInput.press('Enter') + await comfyPage.nextFrame() + + const renamedNode = + comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler') + await expect(renamedNode).toBeVisible() + }) + + test('should copy and paste node via context menu', async ({ + comfyPage + }) => { + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await openContextMenu(comfyPage, 'Load Checkpoint') + await clickExactMenuItem(comfyPage, 'Copy') + + // Internal clipboard paste (menu Copy uses canvas clipboard, not OS) + await comfyPage.page.evaluate(() => { + window.app!.canvas.pasteFromClipboard({ connectInputs: false }) + }) + await comfyPage.nextFrame() + + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe( + initialCount + 1 + ) + }) + + test('should duplicate node via context menu', async ({ comfyPage }) => { + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await openContextMenu(comfyPage, 'Load Checkpoint') + await clickExactMenuItem(comfyPage, 'Duplicate') + + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe( + initialCount + 1 + ) + }) + + test('should pin and unpin node via context menu', async ({ + comfyPage + }) => { + const nodeTitle = 'Load Checkpoint' + const nodeRef = await getNodeRef(comfyPage, nodeTitle) + + // Pin via context menu + await openContextMenu(comfyPage, nodeTitle) + await clickExactMenuItem(comfyPage, 'Pin') + + const pinIndicator = comfyPage.vueNodes + .getNodeByTitle(nodeTitle) + .locator(PIN_INDICATOR) + await expect(pinIndicator).toBeVisible() + expect(await nodeRef.isPinned()).toBe(true) + + // Verify drag blocked + const header = comfyPage.vueNodes + .getNodeByTitle(nodeTitle) + .locator('.lg-node-header') + const posBeforeDrag = await header.boundingBox() + if (!posBeforeDrag) throw new Error('Header not found') + await comfyPage.canvasOps.dragAndDrop( + { x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 }, + { x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 } + ) + const posAfterDrag = await header.boundingBox() + expect(posAfterDrag).toEqual(posBeforeDrag) + + // Unpin via context menu + await openContextMenu(comfyPage, nodeTitle) + await clickExactMenuItem(comfyPage, 'Unpin') + + await expect(pinIndicator).not.toBeVisible() + expect(await nodeRef.isPinned()).toBe(false) + }) + + test('should bypass node and remove bypass via context menu', async ({ + comfyPage + }) => { + const nodeTitle = 'Load Checkpoint' + const nodeRef = await getNodeRef(comfyPage, nodeTitle) + + await openContextMenu(comfyPage, nodeTitle) + await clickExactMenuItem(comfyPage, 'Bypass') + + expect(await nodeRef.isBypassed()).toBe(true) + await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass( + BYPASS_CLASS + ) + + await openContextMenu(comfyPage, nodeTitle) + await clickExactMenuItem(comfyPage, 'Remove Bypass') + + expect(await nodeRef.isBypassed()).toBe(false) + await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass( + BYPASS_CLASS + ) + }) + + test('should minimize and expand node via context menu', async ({ + comfyPage + }) => { + const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler') + await expect(fixture.body).toBeVisible() + + await openContextMenu(comfyPage, 'KSampler') + await clickExactMenuItem(comfyPage, 'Minimize Node') + await expect(fixture.body).not.toBeVisible() + + await openContextMenu(comfyPage, 'KSampler') + await clickExactMenuItem(comfyPage, 'Expand Node') + await expect(fixture.body).toBeVisible() + }) + + test('should convert node to subgraph via context menu', async ({ + comfyPage + }) => { + await openContextMenu(comfyPage, 'KSampler') + await clickExactMenuItem(comfyPage, 'Convert to Subgraph') + + const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') + await expect(subgraphNode).toBeVisible() + + await expect( + comfyPage.vueNodes.getNodeByTitle('KSampler') + ).not.toBeVisible() + }) + }) + + test.describe('Image Node Actions', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']) + await comfyPage.workflow.loadWorkflow('widgets/load_image_widget') + await comfyPage.vueNodes.waitForNodes(1) + }) + + test('should copy image to clipboard via context menu', async ({ + comfyPage + }) => { + await openContextMenu(comfyPage, 'Load Image') + await clickExactMenuItem(comfyPage, 'Copy Image') + + // Verify the clipboard contains an image + const hasImage = await comfyPage.page.evaluate(async () => { + const items = await navigator.clipboard.read() + return items.some((item) => + item.types.some((t) => t.startsWith('image/')) + ) + }) + expect(hasImage).toBe(true) + }) + + test('should paste image to LoadImage node via context menu', async ({ + comfyPage + }) => { + // Capture the original image src from the node's preview + const imagePreview = comfyPage.page.locator('.image-preview img') + const originalSrc = await imagePreview.getAttribute('src') + + // Write a test image into the browser clipboard + await comfyPage.page.evaluate(async () => { + const resp = await fetch('/api/view?filename=example.png&type=input') + const blob = await resp.blob() + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }) + ]) + }) + + // Right-click and select Paste Image + await openContextMenu(comfyPage, 'Load Image') + await clickExactMenuItem(comfyPage, 'Paste Image') + + // Verify the image preview src changed + await expect(imagePreview).not.toHaveAttribute('src', originalSrc!) + }) + + test('should open image in new tab via context menu', async ({ + comfyPage + }) => { + await openContextMenu(comfyPage, 'Load Image') + + const popupPromise = comfyPage.page.waitForEvent('popup') + await clickExactMenuItem(comfyPage, 'Open Image') + const popup = await popupPromise + + expect(popup.url()).toContain('/api/view') + expect(popup.url()).toContain('filename=') + await popup.close() + }) + + test('should download image via Save Image context menu', async ({ + comfyPage + }) => { + await openContextMenu(comfyPage, 'Load Image') + + const downloadPromise = comfyPage.page.waitForEvent('download') + await clickExactMenuItem(comfyPage, 'Save Image') + const download = await downloadPromise + + expect(download.suggestedFilename()).toBeTruthy() + }) + }) + + test.describe('Subgraph Actions', () => { + test('should convert to subgraph and unpack back', async ({ + comfyPage + }) => { + // Convert KSampler to subgraph + await openContextMenu(comfyPage, 'KSampler') + await clickExactMenuItem(comfyPage, 'Convert to Subgraph') + + const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') + await expect(subgraphNode).toBeVisible() + await expect( + comfyPage.vueNodes.getNodeByTitle('KSampler') + ).not.toBeVisible() + + // Unpack the subgraph + await openContextMenu(comfyPage, 'New Subgraph') + await clickExactMenuItem(comfyPage, 'Unpack Subgraph') + + await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible() + await expect( + comfyPage.vueNodes.getNodeByTitle('New Subgraph') + ).not.toBeVisible() + }) + + test('should open properties panel via Edit Subgraph Widgets', async ({ + comfyPage + }) => { + // Convert to subgraph first + await openContextMenu(comfyPage, 'Empty Latent Image') + await clickExactMenuItem(comfyPage, 'Convert to Subgraph') + await comfyPage.nextFrame() + + // Right-click subgraph and edit widgets + await openContextMenu(comfyPage, 'New Subgraph') + await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets') + + await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible() + }) + + test('should add subgraph to library and find in node library', async ({ + comfyPage + }) => { + // Convert to subgraph first + await openContextMenu(comfyPage, 'KSampler') + await clickExactMenuItem(comfyPage, 'Convert to Subgraph') + await comfyPage.nextFrame() + + // Add to library + await openContextMenu(comfyPage, 'New Subgraph') + await clickExactMenuItem(comfyPage, 'Add Subgraph to Library') + + // Fill the blueprint name + await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' }) + await comfyPage.nodeOps.fillPromptDialog('TestBlueprint') + + // Open node library sidebar and search for the blueprint + await comfyPage.page.getByRole('button', { name: 'Node Library' }).click() + await comfyPage.nextFrame() + const searchBox = comfyPage.page.getByRole('combobox', { + name: 'Search' + }) + await searchBox.waitFor({ state: 'visible' }) + await searchBox.fill('TestBlueprint') + await comfyPage.nextFrame() + + await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible() + }) + }) + + test.describe('Multi-Node Actions', () => { + const nodeTitles = ['Load Checkpoint', 'KSampler'] + + test('should batch rename selected nodes via context menu', async ({ + comfyPage + }) => { + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Rename') + + await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' }) + await comfyPage.nodeOps.fillPromptDialog('MyNode') + + await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible() + await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible() + }) + + test('should copy and paste selected nodes via context menu', async ({ + comfyPage + }) => { + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Copy') + + await comfyPage.page.evaluate(() => { + window.app!.canvas.pasteFromClipboard({ connectInputs: false }) + }) + await comfyPage.nextFrame() + + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe( + initialCount + nodeTitles.length + ) + }) + + test('should duplicate selected nodes via context menu', async ({ + comfyPage + }) => { + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Duplicate') + + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe( + initialCount + nodeTitles.length + ) + }) + + test('should pin and unpin selected nodes via context menu', async ({ + comfyPage + }) => { + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Pin') + + for (const title of nodeTitles) { + const pinIndicator = comfyPage.vueNodes + .getNodeByTitle(title) + .locator(PIN_INDICATOR) + await expect(pinIndicator).toBeVisible() + } + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Unpin') + + for (const title of nodeTitles) { + const pinIndicator = comfyPage.vueNodes + .getNodeByTitle(title) + .locator(PIN_INDICATOR) + await expect(pinIndicator).not.toBeVisible() + } + }) + + test('should bypass and remove bypass on selected nodes via context menu', async ({ + comfyPage + }) => { + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Bypass') + + for (const title of nodeTitles) { + const nodeRef = await getNodeRef(comfyPage, title) + expect(await nodeRef.isBypassed()).toBe(true) + await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS) + } + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Remove Bypass') + + for (const title of nodeTitles) { + const nodeRef = await getNodeRef(comfyPage, title) + expect(await nodeRef.isBypassed()).toBe(false) + await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass( + BYPASS_CLASS + ) + } + }) + + test('should minimize and expand selected nodes via context menu', async ({ + comfyPage + }) => { + const fixture1 = + await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint') + const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler') + + await expect(fixture1.body).toBeVisible() + await expect(fixture2.body).toBeVisible() + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Minimize Node') + + await expect(fixture1.body).not.toBeVisible() + await expect(fixture2.body).not.toBeVisible() + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Expand Node') + + await expect(fixture1.body).toBeVisible() + await expect(fixture2.body).toBeVisible() + }) + + test('should frame selected nodes via context menu', async ({ + comfyPage + }) => { + const initialGroupCount = await comfyPage.page.evaluate( + () => window.app!.graph.groups.length + ) + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Frame Nodes') + + const newGroupCount = await comfyPage.page.evaluate( + () => window.app!.graph.groups.length + ) + expect(newGroupCount).toBe(initialGroupCount + 1) + }) + + test('should convert to group node via context menu', async ({ + comfyPage + }) => { + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Convert to Group Node') + + await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' }) + await comfyPage.nodeOps.fillPromptDialog('TestGroupNode') + + const groupNodes = await comfyPage.nodeOps.getNodeRefsByType( + 'workflow>TestGroupNode' + ) + expect(groupNodes.length).toBe(1) + }) + + test('should convert selected nodes to subgraph via context menu', async ({ + comfyPage + }) => { + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await openMultiNodeContextMenu(comfyPage, nodeTitles) + await clickExactMenuItem(comfyPage, 'Convert to Subgraph') + + const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') + await expect(subgraphNode).toBeVisible() + + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe( + initialCount - nodeTitles.length + 1 + ) + }) + }) +}) From 161522b13861c0df5026fdbd6c511d1a232d9ee5 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 30 Mar 2026 11:59:00 -0700 Subject: [PATCH 007/218] chore: remove stale tests-ui config (#10736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Removed stale `tests-ui` configuration and documentation references from the repo. ## Why `tests-ui/` no longer exists, but the repo still carried: - a dead `@tests-ui/*` tsconfig path - stale `tests-ui/**/*` include - a Vite watch ignore for a missing directory - documentation examples that still referenced the old path ## Validation - `pnpm format:check` - `pnpm typecheck` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10736-chore-remove-stale-tests-ui-config-3336d73d3650814a98bedfc113b6eb9b) by [Unito](https://www.unito.io) --- docs/FEATURE_FLAGS.md | 2 +- docs/testing/store-testing.md | 14 +++++++------- docs/testing/unit-testing.md | 12 ++++++------ eslint.config.ts | 9 --------- tsconfig.json | 5 +---- vite.config.mts | 1 - 6 files changed, 15 insertions(+), 28 deletions(-) diff --git a/docs/FEATURE_FLAGS.md b/docs/FEATURE_FLAGS.md index 957f2ea447..d5934c455c 100644 --- a/docs/FEATURE_FLAGS.md +++ b/docs/FEATURE_FLAGS.md @@ -363,7 +363,7 @@ Test your feature flags with different combinations: ### Example Test ```typescript -// In tests-ui/tests/api.featureFlags.test.ts +// Example from a colocated unit test it('should handle preview metadata based on feature flag', () => { // Mock server supports feature api.serverFeatureFlags = { supports_preview_metadata: true } diff --git a/docs/testing/store-testing.md b/docs/testing/store-testing.md index 9b736fa36d..889052f561 100644 --- a/docs/testing/store-testing.md +++ b/docs/testing/store-testing.md @@ -17,7 +17,7 @@ This guide covers patterns and examples for testing Pinia stores in the ComfyUI Basic setup for testing Pinia stores: ```typescript -// Example from: tests-ui/tests/store/workflowStore.test.ts +// Example from a colocated store unit test import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -51,7 +51,7 @@ describe('useWorkflowStore', () => { Testing store state changes: ```typescript -// Example from: tests-ui/tests/store/workflowStore.test.ts +// Example from a colocated store unit test it('should create a temporary workflow with a unique path', () => { const workflow = store.createTemporary() expect(workflow.path).toBe('workflows/Unsaved Workflow.json') @@ -72,7 +72,7 @@ it('should create a temporary workflow not clashing with persisted workflows', a Testing store actions: ```typescript -// Example from: tests-ui/tests/store/workflowStore.test.ts +// Example from a colocated store unit test describe('openWorkflow', () => { it('should load and open a temporary workflow', async () => { // Create a test workflow @@ -115,7 +115,7 @@ describe('openWorkflow', () => { Testing store getters: ```typescript -// Example from: tests-ui/tests/store/modelStore.test.ts +// Example from a colocated store unit test describe('getters', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -162,7 +162,7 @@ describe('getters', () => { Mocking API and other dependencies: ```typescript -// Example from: tests-ui/tests/store/workflowStore.test.ts +// Example from a colocated store unit test // Add mock for api at the top of the file vi.mock('@/scripts/api', () => ({ api: { @@ -205,7 +205,7 @@ describe('syncWorkflows', () => { Testing store watchers and reactive behavior: ```typescript -// Example from: tests-ui/tests/store/workflowStore.test.ts +// Example from a colocated store unit test import { nextTick } from 'vue' describe('Subgraphs', () => { @@ -253,7 +253,7 @@ describe('Subgraphs', () => { Testing store integration with other parts of the application: ```typescript -// Example from: tests-ui/tests/store/workflowStore.test.ts +// Example from a colocated store unit test describe('renameWorkflow', () => { it('should rename workflow and update bookmarks', async () => { const workflow = store.createTemporary('dir/test.json') diff --git a/docs/testing/unit-testing.md b/docs/testing/unit-testing.md index aa042ca089..e2da21875b 100644 --- a/docs/testing/unit-testing.md +++ b/docs/testing/unit-testing.md @@ -18,7 +18,7 @@ This guide covers patterns and examples for unit testing utilities, composables, Testing Vue composables requires handling reactivity correctly: ```typescript -// Example from: tests-ui/tests/composables/useServerLogs.test.ts +// Example from a colocated composable unit test import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { useServerLogs } from '@/composables/useServerLogs' @@ -59,7 +59,7 @@ describe('useServerLogs', () => { Testing LiteGraph-related functionality: ```typescript -// Example from: tests-ui/tests/litegraph.test.ts +// Example from a colocated LiteGraph unit test import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph' import { describe, expect, it } from 'vitest' @@ -93,7 +93,7 @@ describe('LGraph', () => { Testing with ComfyUI workflow files: ```typescript -// Example from: tests-ui/tests/comfyWorkflow.test.ts +// Example from a colocated workflow unit test import { describe, expect, it } from 'vitest' import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' import { defaultGraph } from '@/scripts/defaultGraph' @@ -125,7 +125,7 @@ describe('workflow validation', () => { Mocking the ComfyUI API object: ```typescript -// Example from: tests-ui/tests/composables/useServerLogs.test.ts +// Example from a colocated composable unit test import { describe, expect, it, vi } from 'vitest' import { api } from '@/scripts/api' @@ -183,7 +183,7 @@ describe('Function using debounce', () => { When you need to test real debounce/throttle behavior: ```typescript -// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts +// Example from a colocated composable unit test import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('debounced function', () => { @@ -223,7 +223,7 @@ describe('debounced function', () => { Creating mock node definitions for testing: ```typescript -// Example from: tests-ui/tests/apiTypes.test.ts +// Example from a colocated schema unit test import { describe, expect, it } from 'vitest' import { type ComfyNodeDef, diff --git a/eslint.config.ts b/eslint.config.ts index 7455740a44..010111f8af 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -230,15 +230,6 @@ export default defineConfig([ ] } }, - { - files: ['tests-ui/**/*'], - rules: { - '@typescript-eslint/consistent-type-imports': [ - 'error', - { disallowTypeAnnotations: false } - ] - } - }, { files: ['**/*.spec.ts'], ignores: ['browser_tests/tests/**/*.spec.ts'], diff --git a/tsconfig.json b/tsconfig.json index 1b29384062..fa6f56d78a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,8 +26,7 @@ ], "@/utils/networkUtil": [ "./packages/shared-frontend-utils/src/networkUtil.ts" - ], - "@tests-ui/*": ["./tests-ui/*"] + ] }, "typeRoots": ["src/types", "node_modules/@types", "./node_modules"], "types": [ @@ -49,8 +48,6 @@ "src/types/**/*.d.ts", "playwright.config.ts", "playwright.i18n.config.ts", - - "tests-ui/**/*", "vite.config.mts", "vitest.config.ts" // "vitest.setup.ts", diff --git a/vite.config.mts b/vite.config.mts index a23bd4db53..6857180742 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -161,7 +161,6 @@ export default defineConfig({ ignored: [ './browser_tests/**', './node_modules/**', - './tests-ui/**', '.eslintcache', '.oxlintrc.json', '*.config.{ts,mts}', From e11a1776edec5c1600f563966c7f162483b1dcd4 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Tue, 31 Mar 2026 04:12:38 +0900 Subject: [PATCH 008/218] fix: prevent saving active workflow content to inactive tab on close (#10745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Closing an inactive workflow tab and clicking "Save" overwrites that workflow with the **active** tab's content, causing permanent data loss - `saveWorkflow()` and `saveWorkflowAs()` call `checkState()` which serializes `app.rootGraph` (the active canvas) into the inactive workflow's `changeTracker.activeState` - Guard `checkState()` to only run when the workflow being saved is the active one — in both `saveWorkflow` and `saveWorkflowAs` ## Linked Issues - Fixes https://github.com/Comfy-Org/ComfyUI/issues/13230 ## Root Cause PR #9137 (commit `9fb93a5b0`, v1.41.7) added `workflow.changeTracker?.checkState()` inside `saveWorkflow()` and `saveWorkflowAs()`. `checkState()` always serializes `app.rootGraph` — the graph on the canvas. When called on an inactive tab's change tracker, it captures the active tab's data instead. ## Test plan - [x] E2E: "Closing an inactive tab with save preserves its own content" — persisted workflow B with added node, close while A is active, re-open and verify - [x] E2E: "Closing an inactive unsaved tab with save preserves its own content" — temporary workflow B with added node, close while A is active, save-as with filename, re-open and verify - [x] Manual: open A and B, edit B, switch to A, close B tab, click Save, re-open B — content should be B's not A's --- .../tests/workflowPersistence.spec.ts | 168 ++++++++++++++++++ .../workflow/core/services/workflowService.ts | 6 +- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/browser_tests/tests/workflowPersistence.spec.ts b/browser_tests/tests/workflowPersistence.spec.ts index 0ee6084d11..9b877461e3 100644 --- a/browser_tests/tests/workflowPersistence.spec.ts +++ b/browser_tests/tests/workflowPersistence.spec.ts @@ -323,6 +323,174 @@ test.describe('Workflow Persistence', () => { expect(linkCountAfter).toBe(linkCountBefore) }) + test('Closing an inactive tab with save preserves its own content', async ({ + comfyPage + }) => { + test.info().annotations.push({ + type: 'regression', + description: + 'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead' + }) + + await comfyPage.settings.setSetting( + 'Comfy.Workflow.WorkflowTabsPosition', + 'Topbar' + ) + + const suffix = Date.now().toString(36) + const nameA = `test-A-${suffix}` + const nameB = `test-B-${suffix}` + + // Save the default workflow as A + await comfyPage.menu.topbar.saveWorkflow(nameA) + const nodeCountA = await comfyPage.nodeOps.getNodeCount() + + // Create B: duplicate and save + await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow') + await comfyPage.nextFrame() + await comfyPage.menu.topbar.saveWorkflow(nameB) + + // Add a Note node in B to mark it as modified + await comfyPage.page.evaluate(() => { + window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {})) + }) + await comfyPage.nextFrame() + + const nodeCountB = await comfyPage.nodeOps.getNodeCount() + expect(nodeCountB).toBe(nodeCountA + 1) + + // Trigger checkState so isModified is set + await comfyPage.page.evaluate(() => { + const em = window.app!.extensionManager as unknown as Record< + string, + { activeWorkflow?: { changeTracker?: { checkState(): void } } } + > + em.workflow?.activeWorkflow?.changeTracker?.checkState() + }) + + // Switch to A via topbar tab (making B inactive) + await comfyPage.menu.topbar.getWorkflowTab(nameA).click() + await comfyPage.workflow.waitForWorkflowIdle() + await expect + .poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 }) + .toBe(nodeCountA) + + // Close inactive B tab via middle-click — triggers "Save before closing?" + await comfyPage.menu.topbar.getWorkflowTab(nameB).click({ + button: 'middle' + }) + + // Click "Save" in the dirty close dialog + const saveButton = comfyPage.page.getByRole('button', { name: 'Save' }) + await saveButton.waitFor({ state: 'visible' }) + await saveButton.click() + await comfyPage.workflow.waitForWorkflowIdle() + await comfyPage.nextFrame() + + // Verify we're still on A with A's content + await expect + .poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 }) + .toBe(nodeCountA) + + // Re-open B from sidebar saved list + const workflowsTab = comfyPage.menu.workflowsTab + await workflowsTab.open() + await workflowsTab.getPersistedItem(nameB).dblclick() + await comfyPage.workflow.waitForWorkflowIdle() + + // B should have the extra Note node we added, not A's node count + await expect + .poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 }) + .toBe(nodeCountB) + }) + + test('Closing an inactive unsaved tab with save preserves its own content', async ({ + comfyPage + }) => { + test.info().annotations.push({ + type: 'regression', + description: + 'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph' + }) + + await comfyPage.settings.setSetting( + 'Comfy.Workflow.WorkflowTabsPosition', + 'Topbar' + ) + + const suffix = Date.now().toString(36) + const nameA = `test-A-${suffix}` + const nameB = `test-B-${suffix}` + + // Save the default workflow as A + await comfyPage.menu.topbar.saveWorkflow(nameA) + const nodeCountA = await comfyPage.nodeOps.getNodeCount() + + // Create B as an unsaved workflow with a Note node + await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow') + await comfyPage.nextFrame() + + await comfyPage.page.evaluate(() => { + window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {})) + }) + await comfyPage.nextFrame() + + // Trigger checkState so isModified is set + await comfyPage.page.evaluate(() => { + const em = window.app!.extensionManager as unknown as Record< + string, + { activeWorkflow?: { changeTracker?: { checkState(): void } } } + > + em.workflow?.activeWorkflow?.changeTracker?.checkState() + }) + + const nodeCountB = await comfyPage.nodeOps.getNodeCount() + expect(nodeCountB).toBe(1) + expect(nodeCountA).not.toBe(nodeCountB) + + // Switch to A via topbar tab (making unsaved B inactive) + await comfyPage.menu.topbar.getWorkflowTab(nameA).click() + await comfyPage.workflow.waitForWorkflowIdle() + await expect + .poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 }) + .toBe(nodeCountA) + + // Close inactive unsaved B tab — triggers "Save before closing?" + await comfyPage.menu.topbar + .getWorkflowTab('Unsaved Workflow') + .click({ button: 'middle' }) + + // Click "Save" in the dirty close dialog (scoped to dialog) + const dialog = comfyPage.page.getByRole('dialog') + const saveButton = dialog.getByRole('button', { name: 'Save' }) + await saveButton.waitFor({ state: 'visible' }) + await saveButton.click() + + // Fill in the filename dialog + const saveDialog = comfyPage.menu.topbar.getSaveDialog() + await saveDialog.waitFor({ state: 'visible' }) + await saveDialog.fill(nameB) + await comfyPage.page.keyboard.press('Enter') + await comfyPage.workflow.waitForWorkflowIdle() + await comfyPage.nextFrame() + + // Verify we're still on A with A's content + await expect + .poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 }) + .toBe(nodeCountA) + + // Re-open B from sidebar saved list + const workflowsTab = comfyPage.menu.workflowsTab + await workflowsTab.open() + await workflowsTab.getPersistedItem(nameB).dblclick() + await comfyPage.workflow.waitForWorkflowIdle() + + // B should have 1 node (the Note), not A's node count + await expect + .poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 }) + .toBe(nodeCountB) + }) + test('Splitter panel sizes persist correctly in localStorage', async ({ comfyPage }) => { diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts index 6e89dc9dd4..1f6dcf644f 100644 --- a/src/platform/workflow/core/services/workflowService.ts +++ b/src/platform/workflow/core/services/workflowService.ts @@ -139,7 +139,7 @@ export const useWorkflowService = () => { } if (isSelfOverwrite) { - workflow.changeTracker?.checkState() + if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState() await saveWorkflow(workflow) } else { let target: ComfyWorkflow @@ -156,7 +156,7 @@ export const useWorkflowService = () => { app.rootGraph.extra.linearMode = isApp target.initialMode = isApp ? 'app' : 'graph' } - target.changeTracker?.checkState() + if (workflowStore.isActive(target)) target.changeTracker?.checkState() await workflowStore.saveWorkflow(target) } @@ -173,7 +173,7 @@ export const useWorkflowService = () => { if (workflow.isTemporary) { await saveWorkflowAs(workflow) } else { - workflow.changeTracker?.checkState() + if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState() const isApp = workflow.initialMode === 'app' const expectedPath = From 86a3938d115ffd1487eb488d28db1141fa08f6c4 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 30 Mar 2026 12:24:09 -0700 Subject: [PATCH 009/218] test: add runtime-safe browser_tests alias (#10735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Added a runtime-safe `#e2e/*` alias for `browser_tests`, updated the browser test docs, and migrated a representative fixture/spec import path to the new convention. ## Why `@/*` only covers `src/`, so browser test imports were falling back to deep relative paths. `#e2e/*` resolves in both Node/Playwright runtime and TypeScript. ## Validation - `pnpm format` - `pnpm typecheck:browser` - `pnpm exec playwright test browser_tests/tests/actionbar.spec.ts --list` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10735-test-add-runtime-safe-browser_tests-alias-3336d73d36508122b253cb36a4ead1c1) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown --- browser_tests/README.md | 8 +++- browser_tests/fixtures/ComfyPage.ts | 62 +++++++++++++-------------- browser_tests/tests/actionbar.spec.ts | 4 +- tsconfig.json | 1 + 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/browser_tests/README.md b/browser_tests/README.md index 6240285b34..d9133ce008 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -119,7 +119,7 @@ When writing new tests, follow these patterns: ```typescript // Import the test fixture -import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' test.describe('Feature Name', () => { // Set up test environment if needed @@ -148,6 +148,12 @@ Always check for existing helpers and fixtures before implementing new ones: Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable. +### Import Conventions + +- Prefer `@e2e/*` for imports within `browser_tests/` +- Continue using `@/*` for imports from `src/` +- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available + ### Key Testing Patterns 1. **Focus elements explicitly**: diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 386293139b..df73c39537 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -2,42 +2,42 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' import { test as base } from '@playwright/test' import { config as dotenvConfig } from 'dotenv' -import { TestIds } from './selectors' -import { sleep } from './utils/timing' -import { comfyExpect } from './utils/customMatchers' import { NodeBadgeMode } from '../../src/types/nodeSource' -import { ComfyActionbar } from '../helpers/actionbar' -import { ComfyTemplates } from '../helpers/templates' -import { ComfyMouse } from './ComfyMouse' -import { VueNodeHelpers } from './VueNodeHelpers' -import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' -import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2' -import { ContextMenu } from './components/ContextMenu' -import { SettingDialog } from './components/SettingDialog' -import { BottomPanel } from './components/BottomPanel' -import { QueuePanel } from './components/QueuePanel' +import { ComfyActionbar } from '@e2e/helpers/actionbar' +import { ComfyTemplates } from '@e2e/helpers/templates' +import { ComfyMouse } from '@e2e/fixtures/ComfyMouse' +import { TestIds } from '@e2e/fixtures/selectors' +import { comfyExpect } from '@e2e/fixtures/utils/customMatchers' +import { assetPath } from '@e2e/fixtures/utils/paths' +import { sleep } from '@e2e/fixtures/utils/timing' +import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers' +import { BottomPanel } from '@e2e/fixtures/components/BottomPanel' +import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox' +import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2' +import { ContextMenu } from '@e2e/fixtures/components/ContextMenu' +import { QueuePanel } from '@e2e/fixtures/components/QueuePanel' +import { SettingDialog } from '@e2e/fixtures/components/SettingDialog' import { AssetsSidebarTab, NodeLibrarySidebarTab, WorkflowsSidebarTab -} from './components/SidebarTab' -import { Topbar } from './components/Topbar' -import { AssetsHelper } from './helpers/AssetsHelper' -import { CanvasHelper } from './helpers/CanvasHelper' -import { PerformanceHelper } from './helpers/PerformanceHelper' -import { QueueHelper } from './helpers/QueueHelper' -import { ClipboardHelper } from './helpers/ClipboardHelper' -import { CommandHelper } from './helpers/CommandHelper' -import { DragDropHelper } from './helpers/DragDropHelper' -import { FeatureFlagHelper } from './helpers/FeatureFlagHelper' -import { KeyboardHelper } from './helpers/KeyboardHelper' -import { NodeOperationsHelper } from './helpers/NodeOperationsHelper' -import { SettingsHelper } from './helpers/SettingsHelper' -import { AppModeHelper } from './helpers/AppModeHelper' -import { SubgraphHelper } from './helpers/SubgraphHelper' -import { ToastHelper } from './helpers/ToastHelper' -import { WorkflowHelper } from './helpers/WorkflowHelper' -import { assetPath } from './utils/paths' +} from '@e2e/fixtures/components/SidebarTab' +import { Topbar } from '@e2e/fixtures/components/Topbar' +import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper' +import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper' +import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper' +import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper' +import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper' +import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper' +import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper' +import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper' +import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper' +import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper' +import { QueueHelper } from '@e2e/fixtures/helpers/QueueHelper' +import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper' +import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper' +import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper' +import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper' import type { WorkspaceStore } from '../types/globals' dotenvConfig() diff --git a/browser_tests/tests/actionbar.spec.ts b/browser_tests/tests/actionbar.spec.ts index 4d57b65f48..67c533c495 100644 --- a/browser_tests/tests/actionbar.spec.ts +++ b/browser_tests/tests/actionbar.spec.ts @@ -2,8 +2,8 @@ import type { Response } from '@playwright/test' import { expect, mergeTests } from '@playwright/test' import type { StatusWsMessage } from '../../src/schemas/apiSchema' -import { comfyPageFixture } from '../fixtures/ComfyPage' -import { webSocketFixture } from '../fixtures/ws' +import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' +import { webSocketFixture } from '@e2e/fixtures/ws' import type { WorkspaceStore } from '../types/globals' const test = mergeTests(comfyPageFixture, webSocketFixture) diff --git a/tsconfig.json b/tsconfig.json index fa6f56d78a..23162bafc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "verbatimModuleSyntax": true, "paths": { "@/*": ["./src/*"], + "@e2e/*": ["./browser_tests/*"], "@/utils/formatUtil": [ "./packages/shared-frontend-utils/src/formatUtil.ts" ], From 4cbf4994e91546b55b7ec591aace1082e7178dcf Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Tue, 31 Mar 2026 09:51:39 +0900 Subject: [PATCH 010/218] 1.43.11 (#10763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.43.11 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10763-1-43-11-3346d73d3650814f922fd9405cde85b1) by [Unito](https://www.unito.io) --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions --- package.json | 2 +- src/locales/en/nodeDefs.json | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 602c264492..a453fd1cf1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.43.10", + "version": "1.43.11", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 1c6e97166b..d6a9aadb5a 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -798,7 +798,7 @@ } }, "CaseConverter": { - "display_name": "Case Converter", + "display_name": "Text Case Converter", "inputs": { "string": { "name": "string" @@ -12840,7 +12840,7 @@ } }, "RegexExtract": { - "display_name": "Regex Extract", + "display_name": "Text Extract Substring", "inputs": { "string": { "name": "string" @@ -12871,7 +12871,7 @@ } }, "RegexMatch": { - "display_name": "Regex Match", + "display_name": "Text Match", "inputs": { "string": { "name": "string" @@ -12897,7 +12897,7 @@ } }, "RegexReplace": { - "display_name": "Regex Replace", + "display_name": "Text Replace (Regex)", "description": "Find and replace text using regex patterns.", "inputs": { "string": { @@ -15220,7 +15220,7 @@ } }, "StringCompare": { - "display_name": "Compare", + "display_name": "Text Compare", "inputs": { "string_a": { "name": "string_a" @@ -15242,7 +15242,7 @@ } }, "StringConcatenate": { - "display_name": "Concatenate", + "display_name": "Text Concatenate", "inputs": { "string_a": { "name": "string_a" @@ -15261,7 +15261,7 @@ } }, "StringContains": { - "display_name": "Contains", + "display_name": "Text Contains", "inputs": { "string": { "name": "string" @@ -15281,7 +15281,7 @@ } }, "StringLength": { - "display_name": "Length", + "display_name": "Text Length", "inputs": { "string": { "name": "string" @@ -15295,7 +15295,7 @@ } }, "StringReplace": { - "display_name": "Replace", + "display_name": "Text Replace", "inputs": { "string": { "name": "string" @@ -15314,7 +15314,7 @@ } }, "StringSubstring": { - "display_name": "Substring", + "display_name": "Text Substring", "inputs": { "string": { "name": "string" @@ -15333,7 +15333,7 @@ } }, "StringTrim": { - "display_name": "Trim", + "display_name": "Text Trim", "inputs": { "string": { "name": "string" From 1624750a0208f76f349b769abe5d19a1a1f13c01 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Mon, 30 Mar 2026 18:38:25 -0700 Subject: [PATCH 011/218] fix(test): fix bulk context menu test using correct Playwright patterns (#10762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary Fixes the `Bulk context menu shows when multiple assets selected` test that is failing on main. **Root cause — two issues:** 1. `click({ modifiers: ['ControlOrMeta'] })` does not fire `keydown` events that VueUse's `useKeyModifier('Control')` tracks (used in `useAssetSelection.ts`). Multi-select silently fails because the composable never sees the Control key pressed. Fix: use `keyboard.down('Control')` / `keyboard.up('Control')` around the click. 2. `click({ button: 'right' })` can be intercepted by canvas overlays (documented gotcha in `browser_tests/AGENTS.md`). Fix: use `dispatchEvent('contextmenu', { bubbles: true, cancelable: true })` which bypasses overlay interception. Also removed the `toPass()` retry wrapper since the root causes are now addressed directly. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10762-fix-test-fix-bulk-context-menu-test-using-correct-Playwright-patterns-3346d73d3650811c843ee4a39d3ab305) by [Unito](https://www.unito.io) --------- Co-authored-by: Glary-Bot --- browser_tests/tests/sidebar/assets.spec.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/browser_tests/tests/sidebar/assets.spec.ts b/browser_tests/tests/sidebar/assets.spec.ts index 6dce1cdab0..7e4fd02e6f 100644 --- a/browser_tests/tests/sidebar/assets.spec.ts +++ b/browser_tests/tests/sidebar/assets.spec.ts @@ -527,20 +527,27 @@ test.describe('Assets sidebar - context menu', () => { // Dismiss any toasts that appeared after asset loading await tab.dismissToasts() - // Multi-select: click first, then Ctrl/Cmd+click second + // Multi-select: use keyboard.down/up so useKeyModifier('Control') detects + // the modifier — click({ modifiers }) only sets the mouse event flag and + // does not fire a keydown event that VueUse tracks. await cards.first().click() - await cards.nth(1).click({ modifiers: ['ControlOrMeta'] }) + await comfyPage.page.keyboard.down('Control') + await cards.nth(1).click() + await comfyPage.page.keyboard.up('Control') // Verify multi-selection took effect and footer is stable before right-clicking await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 }) await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 }) - // Right-click on a selected card (retry to let grid layout settle) + // Use dispatchEvent instead of click({ button: 'right' }) to avoid any + // overlay intercepting the event, and assert directly without toPass. const contextMenu = comfyPage.page.locator('.p-contextmenu') - await expect(async () => { - await cards.first().click({ button: 'right' }) - await expect(contextMenu).toBeVisible() - }).toPass({ intervals: [300], timeout: 5000 }) + await cards.first().dispatchEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2 + }) + await expect(contextMenu).toBeVisible() // Bulk menu should show bulk download action await expect(tab.contextMenuItem('Download all')).toBeVisible() From 661e3d7949c65a27e100e5365030f440699590c6 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Mon, 30 Mar 2026 19:20:18 -0700 Subject: [PATCH 012/218] test: migrate `as unknown as` to @total-typescript/shoehorn (#10761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary - Replace all `as unknown as Type` assertions in 59 unit test files with type-safe `@total-typescript/shoehorn` functions - Use `fromPartial()` for partial mock objects where deep-partial type-checks (21 files) - Use `fromAny()` for fundamentally incompatible types: null, undefined, primitives, variables, class expressions, and mocks with test-specific extra properties that `PartialDeepObject` rejects (remaining files) - All explicit type parameters preserved so TypeScript return types are correct - Browser test `.spec.ts` files excluded (shoehorn unavailable in `page.evaluate` browser context) ## Verification - `pnpm typecheck` ✅ - `pnpm lint` ✅ - `pnpm format` ✅ - Pre-commit hooks passed (format + oxlint + eslint + typecheck) - Migrated test files verified passing (ran representative subset) - No test behavior changes — only type assertion syntax changed - No UI changes — screenshots not applicable ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10761-test-migrate-as-unknown-as-to-total-typescript-shoehorn-3336d73d365081f6b8adc44db5dcc380) by [Unito](https://www.unito.io) --------- Co-authored-by: Glary-Bot Co-authored-by: Amp --- package.json | 1 + pnpm-lock.yaml | 11 ++ pnpm-workspace.yaml | 1 + src/base/common/downloadUtil.test.ts | 118 +++++++++------- src/components/graph/DomWidgets.test.ts | 11 +- .../graph/widgets/DomWidget.test.ts | 10 +- .../graph/widgets/domWidgetZIndex.test.ts | 4 +- .../errors/swapNodeGroups.test.ts | 3 +- .../errors/useErrorGroups.test.ts | 3 +- .../parameters/WidgetActions.test.ts | 14 +- .../parameters/WidgetItem.test.ts | 7 +- .../element/useDomClipping.test.ts | 9 +- .../graph/useErrorClearingHooks.test.ts | 5 +- .../graph/useGraphHierarchy.test.ts | 9 +- .../graph/useGraphNodeManager.test.ts | 25 ++-- .../graph/useImageMenuOptions.test.ts | 20 +-- .../maskeditor/useMaskEditorSaver.test.ts | 17 +-- .../node/useNodeImageUpload.test.ts | 5 +- .../node/useNodePreviewAndDrag.test.ts | 22 +-- src/composables/useServerLogs.test.ts | 9 +- src/composables/useWaveAudioPlayer.test.ts | 11 +- .../graph/subgraph/matchPromotedInput.test.ts | 28 ++-- .../graph/subgraph/promotedWidgetView.test.ts | 133 ++++++++++-------- .../graph/subgraph/promotionUtils.test.ts | 5 +- .../subgraph/resolveSubgraphInputLink.test.ts | 5 +- .../widgets/matchTypeConfiguring.test.ts | 9 +- .../src/LGraphCanvas.groupSelection.test.ts | 4 +- .../litegraph/src/subgraph/Subgraph.test.ts | 4 +- .../src/subgraph/SubgraphNode.test.ts | 25 ++-- .../src/subgraph/svgBitmapCache.test.ts | 5 +- .../src/utils/textMeasureCache.test.ts | 5 +- .../litegraph/src/widgets/BaseWidget.test.ts | 3 +- .../composables/useMediaAssetActions.test.ts | 12 +- .../missingModel/missingModelScan.test.ts | 71 +++++----- .../nodeReplacement/cnrIdUtil.test.ts | 16 +-- .../components/SwapNodeGroupRow.test.ts | 9 +- .../nodeReplacement/missingNodeScan.test.ts | 11 +- .../useNodeReplacement.test.ts | 25 ++-- .../OpenSharedWorkflowDialogContent.test.ts | 9 +- .../useSharedWorkflowUrlLoader.test.ts | 7 +- .../validation/schemas/workflowSchema.test.ts | 39 ++--- .../useCreateWorkspaceUrlLoader.test.ts | 3 +- .../composables/useInviteUrlLoader.test.ts | 5 +- src/renderer/core/canvas/useAutoPan.test.ts | 11 +- .../linearMode/flattenNodeOutput.test.ts | 23 +-- .../vueNodes/components/NodeWidgets.test.ts | 8 +- .../useSlotLinkInteraction.autoPan.test.ts | 5 +- .../layout/ensureCorrectLayoutScale.test.ts | 3 +- .../vueNodes/layout/useNodeDrag.test.ts | 19 +-- .../components/DisplayCarousel.test.ts | 9 +- .../components/WidgetSelectDropdown.test.ts | 96 +++++++------ src/renderer/glsl/useGLSLPreview.test.ts | 32 +++-- src/stores/appModeStore.test.ts | 35 ++--- src/stores/executionErrorStore.test.ts | 7 +- src/stores/nodeOutputStore.test.ts | 7 +- src/stores/queueStore.loadWorkflow.test.ts | 7 +- src/stores/resultItemParsing.test.ts | 13 +- src/stores/subgraphNavigationStore.test.ts | 8 +- src/stores/subgraphStore.test.ts | 35 ++--- src/utils/nodeDefUtil.test.ts | 6 +- src/utils/widgetUtil.test.ts | 14 +- .../utils/graphHasMissingNodes.test.ts | 11 +- 62 files changed, 617 insertions(+), 480 deletions(-) diff --git a/package.json b/package.json index a453fd1cf1..beeceabedb 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@testing-library/jest-dom": "catalog:", "@testing-library/user-event": "catalog:", "@testing-library/vue": "catalog:", + "@total-typescript/shoehorn": "catalog:", "@types/fs-extra": "catalog:", "@types/jsdom": "catalog:", "@types/node": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9cd524cca..696250e2f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ catalogs: '@tiptap/starter-kit': specifier: ^2.27.2 version: 2.27.2 + '@total-typescript/shoehorn': + specifier: ^0.1.2 + version: 0.1.2 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -651,6 +654,9 @@ importers: '@testing-library/vue': specifier: 'catalog:' version: 8.1.0(@vue/compiler-sfc@3.5.28)(vue@3.5.13(typescript@5.9.3)) + '@total-typescript/shoehorn': + specifier: 'catalog:' + version: 0.1.2 '@types/fs-extra': specifier: 'catalog:' version: 11.0.4 @@ -4274,6 +4280,9 @@ packages: '@tmcp/auth': optional: true + '@total-typescript/shoehorn@0.1.2': + resolution: {integrity: sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw==} + '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -13308,6 +13317,8 @@ snapshots: esm-env: 1.2.2 tmcp: 1.19.0(typescript@5.9.3) + '@total-typescript/shoehorn@0.1.2': {} + '@tweenjs/tween.js@23.1.3': {} '@tybys/wasm-util@0.10.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f94512d0d2..38f0bf994f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,7 @@ catalog: '@tiptap/extension-table-row': ^2.27.2 '@tiptap/pm': 2.27.2 '@tiptap/starter-kit': ^2.27.2 + '@total-typescript/shoehorn': ^0.1.2 '@types/fs-extra': ^11.0.4 '@types/jsdom': ^21.1.7 '@types/node': ^24.1.0 diff --git a/src/base/common/downloadUtil.test.ts b/src/base/common/downloadUtil.test.ts index e601d870c9..916ba36d8a 100644 --- a/src/base/common/downloadUtil.test.ts +++ b/src/base/common/downloadUtil.test.ts @@ -1,3 +1,4 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -43,12 +44,12 @@ describe('downloadUtil', () => { createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url') revokeObjectURLSpy.mockClear().mockImplementation(() => {}) // Create a mock anchor element - mockLink = { + mockLink = fromPartial({ href: '', download: '', click: vi.fn(), style: { display: '' } - } as unknown as HTMLAnchorElement + }) // Spy on DOM methods vi.spyOn(document, 'createElement').mockReturnValue(mockLink) @@ -172,12 +173,14 @@ describe('downloadUtil', () => { const headersMock = { get: vi.fn().mockReturnValue(null) } - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - blob: blobFn, - headers: headersMock - } as unknown as Response) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + }) + ) downloadFile(testUrl) @@ -198,11 +201,13 @@ describe('downloadUtil', () => { mockIsCloud.value = true const testUrl = 'https://storage.googleapis.com/bucket/missing.bin' const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - fetchMock.mockResolvedValue({ - ok: false, - status: 404, - blob: vi.fn() - } as Partial as Response) + fetchMock.mockResolvedValue( + fromPartial({ + ok: false, + status: 404, + blob: vi.fn() + }) + ) downloadFile(testUrl) @@ -224,12 +229,14 @@ describe('downloadUtil', () => { const headersMock = { get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"') } - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - blob: blobFn, - headers: headersMock - } as unknown as Response) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + }) + ) downloadFile(testUrl) @@ -256,12 +263,14 @@ describe('downloadUtil', () => { 'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png' ) } - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - blob: blobFn, - headers: headersMock - } as unknown as Response) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + }) + ) downloadFile(testUrl) @@ -282,12 +291,14 @@ describe('downloadUtil', () => { const headersMock = { get: vi.fn().mockReturnValue(null) } - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - blob: blobFn, - headers: headersMock - } as unknown as Response) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + status: 200, + blob: blobFn, + headers: headersMock + }) + ) downloadFile(testUrl, 'my-fallback.png') @@ -328,11 +339,13 @@ describe('downloadUtil', () => { const testUrl = 'https://storage.googleapis.com/bucket/image.png' const blob = new Blob(['test'], { type: 'image/png' }) const mockTab = { location: { href: '' }, closed: false, close: vi.fn() } - windowOpenSpy.mockReturnValue(mockTab as unknown as Window) - fetchMock.mockResolvedValue({ - ok: true, - blob: vi.fn().mockResolvedValue(blob) - } as unknown as Response) + windowOpenSpy.mockReturnValue(fromAny(mockTab)) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + blob: vi.fn().mockResolvedValue(blob) + }) + ) await openFileInNewTab(testUrl) @@ -346,11 +359,13 @@ describe('downloadUtil', () => { mockIsCloud.value = true const blob = new Blob(['test'], { type: 'image/png' }) const mockTab = { location: { href: '' }, closed: false, close: vi.fn() } - windowOpenSpy.mockReturnValue(mockTab as unknown as Window) - fetchMock.mockResolvedValue({ - ok: true, - blob: vi.fn().mockResolvedValue(blob) - } as unknown as Response) + windowOpenSpy.mockReturnValue(fromAny(mockTab)) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + blob: vi.fn().mockResolvedValue(blob) + }) + ) await openFileInNewTab('https://example.com/image.png') @@ -364,11 +379,10 @@ describe('downloadUtil', () => { const testUrl = 'https://storage.googleapis.com/bucket/missing.png' const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const mockTab = { location: { href: '' }, closed: false, close: vi.fn() } - windowOpenSpy.mockReturnValue(mockTab as unknown as Window) - fetchMock.mockResolvedValue({ - ok: false, - status: 404 - } as unknown as Response) + windowOpenSpy.mockReturnValue(fromAny(mockTab)) + fetchMock.mockResolvedValue( + fromPartial({ ok: false, status: 404 }) + ) await openFileInNewTab(testUrl) @@ -381,11 +395,13 @@ describe('downloadUtil', () => { mockIsCloud.value = true const blob = new Blob(['test'], { type: 'image/png' }) const mockTab = { location: { href: '' }, closed: true, close: vi.fn() } - windowOpenSpy.mockReturnValue(mockTab as unknown as Window) - fetchMock.mockResolvedValue({ - ok: true, - blob: vi.fn().mockResolvedValue(blob) - } as unknown as Response) + windowOpenSpy.mockReturnValue(fromAny(mockTab)) + fetchMock.mockResolvedValue( + fromPartial({ + ok: true, + blob: vi.fn().mockResolvedValue(blob) + }) + ) await openFileInNewTab('https://example.com/image.png') diff --git a/src/components/graph/DomWidgets.test.ts b/src/components/graph/DomWidgets.test.ts index 2b9c1fbf77..1a7cd23222 100644 --- a/src/components/graph/DomWidgets.test.ts +++ b/src/components/graph/DomWidgets.test.ts @@ -1,3 +1,5 @@ +import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' import { mount } from '@vue/test-utils' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,7 +11,6 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import type { BaseDOMWidget } from '@/scripts/domWidget' import { useDomWidgetStore } from '@/stores/domWidgetStore' -import { createTestingPinia } from '@pinia/testing' type TestWidget = BaseDOMWidget @@ -28,7 +29,7 @@ function createNode( } function createWidget(id: string, node: LGraphNode, y = 12): TestWidget { - return { + return fromPartial({ id, node, name: 'test_widget', @@ -40,16 +41,16 @@ function createWidget(id: string, node: LGraphNode, y = 12): TestWidget { computedHeight: 40, margin: 10, isVisible: () => true - } as unknown as TestWidget + }) } function createCanvas(graph: LGraph): LGraphCanvas { - return { + return fromPartial({ graph, low_quality: false, read_only: false, isNodeVisible: vi.fn(() => true) - } as unknown as LGraphCanvas + }) } function drawFrame(canvas: LGraphCanvas) { diff --git a/src/components/graph/widgets/DomWidget.test.ts b/src/components/graph/widgets/DomWidget.test.ts index 11121ed85b..fe4460d3bc 100644 --- a/src/components/graph/widgets/DomWidget.test.ts +++ b/src/components/graph/widgets/DomWidget.test.ts @@ -1,14 +1,14 @@ -import { mount } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' +import { mount } from '@vue/test-utils' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { reactive } from 'vue' -import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' import type { BaseDOMWidget } from '@/scripts/domWidget' import type { DomWidgetState } from '@/stores/domWidgetStore' import { useDomWidgetStore } from '@/stores/domWidgetStore' - +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' import DomWidget from './DomWidget.vue' const mockUpdatePosition = vi.fn() @@ -63,7 +63,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState { } }) - const widget = { + const widget = fromPartial>({ id: 'dom-widget-id', name: 'test_widget', type: 'custom', @@ -71,7 +71,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState { options: {}, node, computedDisabled: false - } as unknown as BaseDOMWidget + }) domWidgetStore.registerWidget(widget) domWidgetStore.setPositionOverride(widget.id, { diff --git a/src/components/graph/widgets/domWidgetZIndex.test.ts b/src/components/graph/widgets/domWidgetZIndex.test.ts index fb578d998c..e5f79fecf4 100644 --- a/src/components/graph/widgets/domWidgetZIndex.test.ts +++ b/src/components/graph/widgets/domWidgetZIndex.test.ts @@ -1,7 +1,7 @@ +import { fromAny } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' - import { getDomWidgetZIndex } from './domWidgetZIndex' describe('getDomWidgetZIndex', () => { @@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => { first.order = 0 second.order = 1 - const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes + const nodes = fromAny<{ _nodes: LGraphNode[] }, unknown>(graph)._nodes nodes.splice(nodes.indexOf(first), 1) nodes.push(first) diff --git a/src/components/rightSidePanel/errors/swapNodeGroups.test.ts b/src/components/rightSidePanel/errors/swapNodeGroups.test.ts index 4af142a372..4c59c9e1a5 100644 --- a/src/components/rightSidePanel/errors/swapNodeGroups.test.ts +++ b/src/components/rightSidePanel/errors/swapNodeGroups.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { nextTick, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -159,7 +160,7 @@ describe('swapNodeGroups computed', () => { it('excludes string nodeType entries', async () => { const swap = getSwapNodeGroups([ - 'StringGroupNode' as unknown as MissingNodeType, + fromAny('StringGroupNode'), makeMissingNodeType('OldNode', { nodeId: '1', isReplaceable: true, diff --git a/src/components/rightSidePanel/errors/useErrorGroups.test.ts b/src/components/rightSidePanel/errors/useErrorGroups.test.ts index 3455badf8f..bb0565c743 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.test.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { nextTick, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -215,7 +216,7 @@ describe('useErrorGroups', () => { const { groups } = createErrorGroups() const missingNodesStore = useMissingNodesErrorStore() missingNodesStore.setMissingNodeTypes([ - 'StringGroupNode' as unknown as MissingNodeType + fromAny('StringGroupNode') ]) await nextTick() diff --git a/src/components/rightSidePanel/parameters/WidgetActions.test.ts b/src/components/rightSidePanel/parameters/WidgetActions.test.ts index 3f14a3e8cb..4c7081006a 100644 --- a/src/components/rightSidePanel/parameters/WidgetActions.test.ts +++ b/src/components/rightSidePanel/parameters/WidgetActions.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { mount } from '@vue/test-utils' import { setActivePinia } from 'pinia' import type { Slots } from 'vue' @@ -10,7 +11,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { usePromotionStore } from '@/stores/promotionStore' - import WidgetActions from './WidgetActions.vue' const { mockGetInputSpecForWidget } = vi.hoisted(() => ({ @@ -93,13 +93,13 @@ describe('WidgetActions', () => { } function createMockNode(): LGraphNode { - return { + return fromAny({ id: 1, type: 'TestNode', rootGraph: { id: 'graph-test' }, computeSize: vi.fn(), size: [200, 100] - } as unknown as LGraphNode + }) } function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) { @@ -216,17 +216,17 @@ describe('WidgetActions', () => { mockGetInputSpecForWidget.mockReturnValue({ type: 'CUSTOM' }) - const parentSubgraphNode = { + const parentSubgraphNode = fromAny({ id: 4, rootGraph: { id: 'graph-test' }, computeSize: vi.fn(), size: [300, 150] - } as unknown as SubgraphNode - const node = { + }) + const node = fromAny({ id: 4, type: 'SubgraphNode', rootGraph: { id: 'graph-test' } - } as unknown as LGraphNode + }) const widget = { name: 'text', type: 'text', diff --git a/src/components/rightSidePanel/parameters/WidgetItem.test.ts b/src/components/rightSidePanel/parameters/WidgetItem.test.ts index c1d8f50eb2..6492278265 100644 --- a/src/components/rightSidePanel/parameters/WidgetItem.test.ts +++ b/src/components/rightSidePanel/parameters/WidgetItem.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { mount } from '@vue/test-utils' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -72,13 +73,13 @@ const i18n = createI18n({ }) function createMockNode(overrides: Partial = {}): LGraphNode { - return { + return fromAny({ id: 1, type: 'TestNode', isSubgraphNode: () => false, graph: { rootGraph: { id: 'test-graph-id' } }, ...overrides - } as unknown as LGraphNode + }) } function createMockWidget(overrides: Partial = {}): IBaseWidget { @@ -128,7 +129,7 @@ function createMockPromotedWidgetView( return 0 } } - return new MockPromotedWidgetView() as unknown as IBaseWidget + return fromAny(new MockPromotedWidgetView()) } function mountWidgetItem( diff --git a/src/composables/element/useDomClipping.test.ts b/src/composables/element/useDomClipping.test.ts index 0900d0d24b..c7d7cedf69 100644 --- a/src/composables/element/useDomClipping.test.ts +++ b/src/composables/element/useDomClipping.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { useDomClipping } from './useDomClipping' @@ -8,7 +9,7 @@ function createMockElement(rect: { width: number height: number }): HTMLElement { - return { + return fromPartial({ getBoundingClientRect: vi.fn( () => ({ @@ -20,7 +21,7 @@ function createMockElement(rect: { toJSON: () => ({}) }) as DOMRect ) - } as unknown as HTMLElement + }) } function createMockCanvas(rect: { @@ -29,7 +30,7 @@ function createMockCanvas(rect: { width: number height: number }): HTMLCanvasElement { - return { + return fromPartial({ getBoundingClientRect: vi.fn( () => ({ @@ -41,7 +42,7 @@ function createMockCanvas(rect: { toJSON: () => ({}) }) as DOMRect ) - } as unknown as HTMLCanvasElement + }) } describe('useDomClipping', () => { diff --git a/src/composables/graph/useErrorClearingHooks.test.ts b/src/composables/graph/useErrorClearingHooks.test.ts index ea82714a84..7a20a04209 100644 --- a/src/composables/graph/useErrorClearingHooks.test.ts +++ b/src/composables/graph/useErrorClearingHooks.test.ts @@ -1,5 +1,6 @@ -import { setActivePinia } from 'pinia' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks' @@ -194,7 +195,7 @@ describe('Widget change error clearing via onWidgetChanged', () => { const store = useExecutionErrorStore() vi.spyOn(app, 'rootGraph', 'get').mockReturnValue( - undefined as unknown as LGraph + fromAny(undefined) ) store.lastNodeErrors = { [String(node.id)]: { diff --git a/src/composables/graph/useGraphHierarchy.test.ts b/src/composables/graph/useGraphHierarchy.test.ts index c7b5a8f819..30cb8968e2 100644 --- a/src/composables/graph/useGraphHierarchy.test.ts +++ b/src/composables/graph/useGraphHierarchy.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle' @@ -8,7 +9,6 @@ import { createMockLGraphNode, createMockLGraphGroup } from '@/utils/__tests__/litegraphTestUtils' - import { useGraphHierarchy } from './useGraphHierarchy' vi.mock('@/renderer/core/canvas/canvasStore') @@ -36,7 +36,10 @@ describe('useGraphHierarchy', () => { mockNode = createMockNode() mockGroups = [] - mockCanvasStore = { + mockCanvasStore = fromAny< + Partial>, + unknown + >({ canvas: { graph: { groups: mockGroups @@ -51,7 +54,7 @@ describe('useGraphHierarchy', () => { $dispose: vi.fn(), _customProperties: new Set(), _p: {} - } as unknown as Partial> + }) vi.mocked(useCanvasStore).mockReturnValue( mockCanvasStore as ReturnType diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 268e07ec57..cb00bbfbd7 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -1,5 +1,6 @@ -import { setActivePinia } from 'pinia' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed, nextTick, watch } from 'vue' @@ -11,10 +12,10 @@ import { createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' -import { app } from '@/scripts/app' -import { useExecutionErrorStore } from '@/stores/executionErrorStore' import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { useSettingStore } from '@/platform/settings/settingStore' +import { app } from '@/scripts/app' +import { useExecutionErrorStore } from '@/stores/executionErrorStore' import { usePromotionStore } from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' @@ -277,18 +278,20 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { const secondPromotedView = promotedViews[1] if (!secondPromotedView) throw new Error('Expected second promoted view') - ;( - secondPromotedView as unknown as { + fromAny< + { sourceNodeId: string sourceWidgetName: string - } - ).sourceNodeId = '9999' - ;( - secondPromotedView as unknown as { + }, + unknown + >(secondPromotedView).sourceNodeId = '9999' + fromAny< + { sourceNodeId: string sourceWidgetName: string - } - ).sourceWidgetName = 'stale_widget' + }, + unknown + >(secondPromotedView).sourceWidgetName = 'stale_widget' const { vueNodeData } = useGraphNodeManager(graph) const nodeData = vueNodeData.get(String(subgraphNode.id)) diff --git a/src/composables/graph/useImageMenuOptions.test.ts b/src/composables/graph/useImageMenuOptions.test.ts index 510a7b927e..bac50a9749 100644 --- a/src/composables/graph/useImageMenuOptions.test.ts +++ b/src/composables/graph/useImageMenuOptions.test.ts @@ -1,8 +1,8 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { afterEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' - import { useImageMenuOptions } from './useImageMenuOptions' vi.mock('vue-i18n', async (importOriginal) => { @@ -112,9 +112,11 @@ describe('useImageMenuOptions', () => { getType: vi.fn().mockResolvedValue(mockBlob) } - mockClipboard({ - read: vi.fn().mockResolvedValue([mockClipboardItem]) - } as unknown as Clipboard) + mockClipboard( + fromPartial({ + read: vi.fn().mockResolvedValue([mockClipboardItem]) + }) + ) const { getImageMenuOptions } = useImageMenuOptions() const options = getImageMenuOptions(node) @@ -131,7 +133,7 @@ describe('useImageMenuOptions', () => { it('handles missing clipboard API gracefully', async () => { const node = createImageNode() - mockClipboard({ read: undefined } as unknown as Clipboard) + mockClipboard(fromPartial({ read: undefined })) const { getImageMenuOptions } = useImageMenuOptions() const options = getImageMenuOptions(node) @@ -148,9 +150,11 @@ describe('useImageMenuOptions', () => { getType: vi.fn() } - mockClipboard({ - read: vi.fn().mockResolvedValue([mockClipboardItem]) - } as unknown as Clipboard) + mockClipboard( + fromPartial({ + read: vi.fn().mockResolvedValue([mockClipboardItem]) + }) + ) const { getImageMenuOptions } = useImageMenuOptions() const options = getImageMenuOptions(node) diff --git a/src/composables/maskeditor/useMaskEditorSaver.test.ts b/src/composables/maskeditor/useMaskEditorSaver.test.ts index c074c6566a..7031bf0189 100644 --- a/src/composables/maskeditor/useMaskEditorSaver.test.ts +++ b/src/composables/maskeditor/useMaskEditorSaver.test.ts @@ -1,10 +1,11 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { app } from '@/scripts/app' import { api } from '@/scripts/api' +import { app } from '@/scripts/app' import { useNodeOutputStore } from '@/stores/nodeOutputStore' import { useMaskEditorSaver } from './useMaskEditorSaver' @@ -21,7 +22,7 @@ vi.mock('@/stores/maskEditorDataStore', () => ({ })) function createMockCtx(): CanvasRenderingContext2D { - return { + return fromPartial({ drawImage: vi.fn(), getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4 * 4 * 4), @@ -30,11 +31,11 @@ function createMockCtx(): CanvasRenderingContext2D { })), putImageData: vi.fn(), globalCompositeOperation: 'source-over' - } as unknown as CanvasRenderingContext2D + }) } function createMockCanvas(): HTMLCanvasElement { - return { + return fromPartial({ width: 4, height: 4, getContext: vi.fn(() => createMockCtx()), @@ -42,7 +43,7 @@ function createMockCanvas(): HTMLCanvasElement { cb(new Blob(['x'], { type: 'image/png' })) }), toDataURL: vi.fn(() => 'data:image/png;base64,mock') - } as unknown as HTMLCanvasElement + }) } const mockEditorStore: Record = { @@ -96,7 +97,7 @@ describe('useMaskEditorSaver', () => { app.nodeOutputs = {} app.nodePreviewImages = {} - mockNode = { + mockNode = fromAny({ id: 42, type: 'LoadImage', images: [], @@ -107,7 +108,7 @@ describe('useMaskEditorSaver', () => { widgets_values: ['original.png [input]'], properties: { image: 'original.png [input]' }, graph: { setDirtyCanvas: vi.fn() } - } as unknown as LGraphNode + }) mockDataStore.sourceNode = mockNode mockDataStore.inputData = { @@ -135,7 +136,7 @@ describe('useMaskEditorSaver', () => { vi.spyOn(document, 'createElement').mockImplementation( (tagName: string, options?: ElementCreationOptions) => { if (tagName === 'canvas') - return createMockCanvas() as unknown as HTMLCanvasElement + return fromAny(createMockCanvas()) return originalCreateElement(tagName, options) } ) diff --git a/src/composables/node/useNodeImageUpload.test.ts b/src/composables/node/useNodeImageUpload.test.ts index ca755faa4a..b03d80237b 100644 --- a/src/composables/node/useNodeImageUpload.test.ts +++ b/src/composables/node/useNodeImageUpload.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -44,12 +45,12 @@ vi.mock('@/stores/assetsStore', () => ({ })) function createMockNode(): LGraphNode { - return { + return fromAny({ isUploading: false, imgs: [new Image()], graph: { setDirtyCanvas: vi.fn() }, size: [300, 400] - } as unknown as LGraphNode + }) } function createFile(name = 'test.png'): File { diff --git a/src/composables/node/useNodePreviewAndDrag.test.ts b/src/composables/node/useNodePreviewAndDrag.test.ts index bad6d3d7fe..79e365a907 100644 --- a/src/composables/node/useNodePreviewAndDrag.test.ts +++ b/src/composables/node/useNodePreviewAndDrag.test.ts @@ -1,8 +1,8 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' - import { useNodePreviewAndDrag } from './useNodePreviewAndDrag' const mockStartDrag = vi.fn() @@ -72,9 +72,9 @@ describe('useNodePreviewAndDrag', () => { toJSON: () => ({}) }) - const mockEvent = { + const mockEvent = fromPartial({ currentTarget: mockElement - } as Partial as MouseEvent + }) result.handleMouseEnter(mockEvent) expect(result.isHovered.value).toBe(true) @@ -85,9 +85,9 @@ describe('useNodePreviewAndDrag', () => { const result = useNodePreviewAndDrag(nodeDef) const mockElement = document.createElement('div') - const mockEvent = { + const mockEvent = fromPartial({ currentTarget: mockElement - } as Partial as MouseEvent + }) result.handleMouseEnter(mockEvent) expect(result.isHovered.value).toBe(false) @@ -116,9 +116,9 @@ describe('useNodePreviewAndDrag', () => { setData: vi.fn(), setDragImage: vi.fn() } - const mockEvent = { + const mockEvent = fromAny({ dataTransfer: mockDataTransfer - } as unknown as DragEvent + }) result.handleDragStart(mockEvent) @@ -151,10 +151,10 @@ describe('useNodePreviewAndDrag', () => { result.isDragging.value = true - const mockEvent = { + const mockEvent = fromPartial({ clientX: 100, clientY: 200 - } as Partial as DragEvent + }) result.handleDragEnd(mockEvent) @@ -168,11 +168,11 @@ describe('useNodePreviewAndDrag', () => { result.isDragging.value = true - const mockEvent = { + const mockEvent = fromPartial({ dataTransfer: { dropEffect: 'none' }, clientX: 300, clientY: 400 - } as Partial as DragEvent + }) result.handleDragEnd(mockEvent) diff --git a/src/composables/useServerLogs.test.ts b/src/composables/useServerLogs.test.ts index c056b6f3a0..afa347a1c3 100644 --- a/src/composables/useServerLogs.test.ts +++ b/src/composables/useServerLogs.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { useEventListener } from '@vueuse/core' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -79,10 +80,10 @@ describe('useServerLogs', () => { // Simulate receiving a log event const mockEvent = new CustomEvent('logs', { - detail: { + detail: fromAny({ type: 'logs', entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }] - } as unknown as LogsWsMessage + }) }) as CustomEvent eventCallback(mockEvent) @@ -103,14 +104,14 @@ describe('useServerLogs', () => { ) => void const mockEvent = new CustomEvent('logs', { - detail: { + detail: fromAny({ type: 'logs', entries: [ { m: 'Log message 1 dont remove me' }, { m: 'remove me' }, { m: '' } ] - } as unknown as LogsWsMessage + }) }) as CustomEvent eventCallback(mockEvent) diff --git a/src/composables/useWaveAudioPlayer.test.ts b/src/composables/useWaveAudioPlayer.test.ts index e84b73b774..f77e8dc440 100644 --- a/src/composables/useWaveAudioPlayer.test.ts +++ b/src/composables/useWaveAudioPlayer.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { ref } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -80,10 +81,12 @@ describe('useWaveAudioPlayer', () => { const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer)) const mockClose = vi.fn().mockResolvedValue(undefined) - globalThis.AudioContext = class { - decodeAudioData = mockDecodeAudioData - close = mockClose - } as unknown as typeof AudioContext + globalThis.AudioContext = fromAny( + class { + decodeAudioData = mockDecodeAudioData + close = mockClose + } + ) mockFetchApi.mockResolvedValue({ ok: true, diff --git a/src/core/graph/subgraph/matchPromotedInput.test.ts b/src/core/graph/subgraph/matchPromotedInput.test.ts index 82787f4057..352f0464b9 100644 --- a/src/core/graph/subgraph/matchPromotedInput.test.ts +++ b/src/core/graph/subgraph/matchPromotedInput.test.ts @@ -1,7 +1,7 @@ +import { fromAny } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' - import { matchPromotedInput } from './matchPromotedInput' type MockInput = { @@ -31,10 +31,13 @@ describe(matchPromotedInput, () => { } const matched = matchPromotedInput( - [aliasInput, exactInput] as unknown as Array<{ - name: string - _widget?: IBaseWidget - }>, + fromAny< + Array<{ + name: string + _widget?: IBaseWidget + }>, + unknown + >([aliasInput, exactInput]), targetWidget ) @@ -48,7 +51,9 @@ describe(matchPromotedInput, () => { } const matched = matchPromotedInput( - [aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>, + fromAny, unknown>([ + aliasInput + ]), targetWidget ) @@ -65,10 +70,13 @@ describe(matchPromotedInput, () => { } const matched = matchPromotedInput( - [firstAliasInput, secondAliasInput] as unknown as Array<{ - name: string - _widget?: IBaseWidget - }>, + fromAny< + Array<{ + name: string + _widget?: IBaseWidget + }>, + unknown + >([firstAliasInput, secondAliasInput]), targetWidget ) diff --git a/src/core/graph/subgraph/promotedWidgetView.test.ts b/src/core/graph/subgraph/promotedWidgetView.test.ts index 75d471ab67..30c2a703df 100644 --- a/src/core/graph/subgraph/promotedWidgetView.test.ts +++ b/src/core/graph/subgraph/promotedWidgetView.test.ts @@ -1,6 +1,7 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { fromAny } from '@total-typescript/shoehorn' // Barrel import must come first to avoid circular dependency // (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel) @@ -97,11 +98,12 @@ function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] { } function callSyncPromotions(node: SubgraphNode) { - ;( - node as unknown as { + fromAny< + { _syncPromotions: () => void - } - )._syncPromotions() + }, + unknown + >(node)._syncPromotions() } describe(createPromotedWidgetView, () => { @@ -156,7 +158,9 @@ describe(createPromotedWidgetView, () => { const [subgraphNode] = setupSubgraph() const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget') // node is defined via Object.defineProperty at runtime but not on the TS interface - expect((view as unknown as Record).node).toBe(subgraphNode) + expect(fromAny, unknown>(view).node).toBe( + subgraphNode + ) }) test('serialize is false', () => { @@ -289,7 +293,7 @@ describe(createPromotedWidgetView, () => { value: 'initial', options: {} } satisfies Pick - const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget + const fallbackWidget = fromAny(fallbackWidgetShape) innerNode.widgets = [fallbackWidget] const widgetValueStore = useWidgetValueStore() @@ -398,13 +402,13 @@ describe(createPromotedWidgetView, () => { subgraphNode.pos = [10, 20] const innerNode = firstInnerNode(innerNodes) const mouse = vi.fn(() => true) - const legacyWidget = { + const legacyWidget = fromAny({ name: 'legacyMouse', type: 'mystery-legacy', value: 'val', options: {}, mouse - } as unknown as IBaseWidget + }) innerNode.widgets = [legacyWidget] const view = createPromotedWidgetView( @@ -1448,17 +1452,20 @@ describe('widgets getter caching', () => { subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas const reconcileSpy = vi.spyOn( - subgraphNode as unknown as { - _buildPromotionReconcileState: ( - entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, - linkedEntries: Array<{ - inputName: string - inputKey: string - sourceNodeId: string - sourceWidgetName: string - }> - ) => unknown - }, + fromAny< + { + _buildPromotionReconcileState: ( + entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, + linkedEntries: Array<{ + inputName: string + inputKey: string + sourceNodeId: string + sourceWidgetName: string + }> + ) => unknown + }, + unknown + >(subgraphNode), '_buildPromotionReconcileState' ) @@ -1478,17 +1485,20 @@ describe('widgets getter caching', () => { subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas const reconcileSpy = vi.spyOn( - subgraphNode as unknown as { - _buildPromotionReconcileState: ( - entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, - linkedEntries: Array<{ - inputName: string - inputKey: string - sourceNodeId: string - sourceWidgetName: string - }> - ) => unknown - }, + fromAny< + { + _buildPromotionReconcileState: ( + entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, + linkedEntries: Array<{ + inputName: string + inputKey: string + sourceNodeId: string + sourceWidgetName: string + }> + ) => unknown + }, + unknown + >(subgraphNode), '_buildPromotionReconcileState' ) @@ -1522,9 +1532,14 @@ describe('widgets getter caching', () => { subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB) const resolveSpy = vi.spyOn( - subgraphNode as unknown as { - _resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown - }, + fromAny< + { + _resolveLinkedPromotionBySubgraphInput: ( + ...args: unknown[] + ) => unknown + }, + unknown + >(subgraphNode), '_resolveLinkedPromotionBySubgraphInput' ) @@ -1923,32 +1938,34 @@ function createFakeCanvasContext() { function createInspectableCanvasContext(fillText = vi.fn()) { const fallback = vi.fn() - return new Proxy( - { - fillText, - beginPath: vi.fn(), - roundRect: vi.fn(), - rect: vi.fn(), - fill: vi.fn(), - stroke: vi.fn(), - moveTo: vi.fn(), - lineTo: vi.fn(), - arc: vi.fn(), - measureText: (text: string) => ({ width: text.length * 8 }), - fillStyle: '#fff', - strokeStyle: '#fff', - textAlign: 'left', - globalAlpha: 1, - lineWidth: 1 - } as Record, - { - get(target, key) { - if (typeof key === 'string' && key in target) - return target[key as keyof typeof target] - return fallback + return fromAny( + new Proxy( + { + fillText, + beginPath: vi.fn(), + roundRect: vi.fn(), + rect: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + measureText: (text: string) => ({ width: text.length * 8 }), + fillStyle: '#fff', + strokeStyle: '#fff', + textAlign: 'left', + globalAlpha: 1, + lineWidth: 1 + } as Record, + { + get(target, key) { + if (typeof key === 'string' && key in target) + return target[key as keyof typeof target] + return fallback + } } - } - ) as unknown as CanvasRenderingContext2D + ) + ) } function createTwoLevelNestedSubgraph() { diff --git a/src/core/graph/subgraph/promotionUtils.test.ts b/src/core/graph/subgraph/promotionUtils.test.ts index 43da1db058..950b5cd9ea 100644 --- a/src/core/graph/subgraph/promotionUtils.test.ts +++ b/src/core/graph/subgraph/promotionUtils.test.ts @@ -1,13 +1,14 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { createTestSubgraph, createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { usePromotionStore } from '@/stores/promotionStore' const updatePreviewsMock = vi.hoisted(() => vi.fn()) @@ -29,7 +30,7 @@ function widget( Pick > ): IBaseWidget { - return { name: 'widget', ...overrides } as unknown as IBaseWidget + return fromAny({ name: 'widget', ...overrides }) } describe('isPreviewPseudoWidget', () => { diff --git a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts index b4c01047c5..c68d262e9e 100644 --- a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts +++ b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' @@ -101,14 +102,14 @@ describe('resolveSubgraphInputLink', () => { vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => { if (typeof linkId !== 'number') return originalGetLink(linkId) if (linkId === stale.linkId) { - return { + return fromPartial>({ resolve: () => ({ inputNode: { inputs: undefined, getWidgetFromSlot: () => ({ name: 'ignored' }) } }) - } as unknown as ReturnType + }) } return originalGetLink(linkId) diff --git a/src/core/graph/widgets/matchTypeConfiguring.test.ts b/src/core/graph/widgets/matchTypeConfiguring.test.ts index 45179b0f52..ac5e4ba15f 100644 --- a/src/core/graph/widgets/matchTypeConfiguring.test.ts +++ b/src/core/graph/widgets/matchTypeConfiguring.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' @@ -72,8 +73,8 @@ describe('MatchType during configure', () => { const link2Id = switchNode.inputs[1].link! const outputTypeBefore = switchNode.outputs[0].type - ;( - app as unknown as { configuringGraphLevel: number } + fromAny<{ configuringGraphLevel: number }, unknown>( + app ).configuringGraphLevel = 1 try { @@ -92,8 +93,8 @@ describe('MatchType during configure', () => { expect(graph.links[link2Id]).toBeDefined() expect(switchNode.outputs[0].type).toBe(outputTypeBefore) } finally { - ;( - app as unknown as { configuringGraphLevel: number } + fromAny<{ configuringGraphLevel: number }, unknown>( + app ).configuringGraphLevel = 0 } }) diff --git a/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts b/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts index ba7ea88b83..4e42b02e18 100644 --- a/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts +++ b/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts @@ -1,7 +1,7 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' - import { LGraph, LGraphCanvas, @@ -60,7 +60,7 @@ function createCanvas(graph: LGraph): LGraphCanvas { el.getContext = vi .fn() - .mockReturnValue(ctx as unknown as CanvasRenderingContext2D) + .mockReturnValue(fromAny(ctx)) el.getBoundingClientRect = vi.fn().mockReturnValue({ left: 0, top: 0, diff --git a/src/lib/litegraph/src/subgraph/Subgraph.test.ts b/src/lib/litegraph/src/subgraph/Subgraph.test.ts index ad01e601bb..5826c4ca94 100644 --- a/src/lib/litegraph/src/subgraph/Subgraph.test.ts +++ b/src/lib/litegraph/src/subgraph/Subgraph.test.ts @@ -6,12 +6,12 @@ * and basic I/O management. */ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' import type { LGraph } from '@/lib/litegraph/src/litegraph' import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph' - import { subgraphTest } from './__fixtures__/subgraphFixtures' import { assertSubgraphStructure, @@ -48,7 +48,7 @@ describe('Subgraph Construction', () => { it('should require a root graph', () => { const subgraphData = createTestSubgraphData() const createWithoutRoot = () => - new Subgraph(null as unknown as LGraph, subgraphData) + new Subgraph(fromAny(null), subgraphData) expect(createWithoutRoot).toThrow('Root graph is required') }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts index d044e5dfc2..265b13b169 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts @@ -4,13 +4,13 @@ * Tests for SubgraphNode instances including construction, * IO synchronization, and edge cases. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation' import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' - +import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation' import { subgraphTest } from './__fixtures__/subgraphFixtures' import { createTestSubgraph, @@ -933,14 +933,17 @@ describe('SubgraphNode promotion view keys', () => { const subgraph = createTestSubgraph() const subgraphNode = createTestSubgraphNode(subgraph) - const nodeWithKeyBuilder = subgraphNode as unknown as { - _makePromotionViewKey: ( - inputKey: string, - interiorNodeId: string, - widgetName: string, - inputName?: string - ) => string - } + const nodeWithKeyBuilder = fromAny< + { + _makePromotionViewKey: ( + inputKey: string, + interiorNodeId: string, + widgetName: string, + inputName?: string + ) => string + }, + unknown + >(subgraphNode) const firstKey = nodeWithKeyBuilder._makePromotionViewKey( '65', diff --git a/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts b/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts index 15a28f5aa7..1dea0113fa 100644 --- a/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts +++ b/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it, vi } from 'vitest' import { createBitmapCache } from './svgBitmapCache' @@ -25,9 +26,9 @@ describe('createBitmapCache', () => { ) } - const stubContext = { + const stubContext = fromPartial({ drawImage: vi.fn() - } as unknown as CanvasRenderingContext2D + }) it('returns the SVG when image is not yet complete', () => { const svg = mockSvg({ complete: false, naturalWidth: 0 }) diff --git a/src/lib/litegraph/src/utils/textMeasureCache.test.ts b/src/lib/litegraph/src/utils/textMeasureCache.test.ts index fdb5782dfd..04bdfc7e77 100644 --- a/src/lib/litegraph/src/utils/textMeasureCache.test.ts +++ b/src/lib/litegraph/src/utils/textMeasureCache.test.ts @@ -1,12 +1,13 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import { cachedMeasureText, clearTextMeasureCache } from './textMeasureCache' function createMockCtx(font = '12px sans-serif'): CanvasRenderingContext2D { - return { + return fromPartial({ font, measureText: vi.fn((text: string) => ({ width: text.length * 7 })) - } as unknown as CanvasRenderingContext2D + }) } describe('textMeasureCache', () => { diff --git a/src/lib/litegraph/src/widgets/BaseWidget.test.ts b/src/lib/litegraph/src/widgets/BaseWidget.test.ts index 0f72dc5e26..8c8c8482cc 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.test.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' @@ -167,7 +168,7 @@ describe('BaseWidget store integration', () => { const defaultValue = 'You are an expert image-generation engine.' const widget = createTestWidget(node, { name: 'system_prompt', - value: undefined as unknown as number + value: fromAny(undefined) }) // Simulate what addDOMWidget does: override value with getter/setter diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts index e5cced26e1..f849cc6def 100644 --- a/src/platform/assets/composables/useMediaAssetActions.test.ts +++ b/src/platform/assets/composables/useMediaAssetActions.test.ts @@ -1,10 +1,10 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' - import { useMediaAssetActions } from './useMediaAssetActions' // Use vi.hoisted to create a mutable reference for isCloud @@ -77,10 +77,12 @@ vi.mock('@/platform/workflow/core/services/workflowActionsService', () => ({ vi.mock('@/services/litegraphService', () => ({ useLitegraphService: () => ({ - addNodeOnGraph: vi.fn().mockReturnValue({ - widgets: [{ name: 'image', value: '', callback: vi.fn() }], - graph: { setDirtyCanvas: vi.fn() } - } as unknown as LGraphNode), + addNodeOnGraph: vi.fn().mockReturnValue( + fromAny({ + widgets: [{ name: 'image', value: '', callback: vi.fn() }], + graph: { setDirtyCanvas: vi.fn() } + }) + ), getCanvasCenter: vi.fn().mockReturnValue([100, 100]) }) })) diff --git a/src/platform/missingModel/missingModelScan.test.ts b/src/platform/missingModel/missingModelScan.test.ts index 4c098d8619..a8d4227d66 100644 --- a/src/platform/missingModel/missingModelScan.test.ts +++ b/src/platform/missingModel/missingModelScan.test.ts @@ -1,5 +1,12 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { + IBaseWidget, + IComboWidget +} from '@/lib/litegraph/src/types/widgets' import { scanAllModelCandidates, isModelFileName, @@ -9,12 +16,6 @@ import { } from '@/platform/missingModel/missingModelScan' import type { MissingModelCandidate } from '@/platform/missingModel/types' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' -import type { LGraph } from '@/lib/litegraph/src/LGraph' -import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' -import type { - IBaseWidget, - IComboWidget -} from '@/lib/litegraph/src/types/widgets' vi.mock('@/utils/graphTraversalUtil', () => ({ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes, @@ -30,32 +31,32 @@ function makeComboWidget( value: string | number, options: string[] = [] ): IComboWidget { - return { + return fromAny({ type: 'combo', name, value, options: { values: options } - } as unknown as IComboWidget + }) } /** Helper: create an asset widget mock (Cloud combo replacement) */ function makeAssetWidget(name: string, value: string): IBaseWidget { - return { + return fromAny({ type: 'asset', name, value, options: {} - } as unknown as IBaseWidget + }) } /** Helper: create a non-combo widget mock */ function makeOtherWidget(name: string, value: unknown): IBaseWidget { - return { + return fromAny({ type: 'number', name, value, options: {} - } as unknown as IBaseWidget + }) } /** Helper: create a mock LGraphNode with configured widgets */ @@ -65,17 +66,17 @@ function makeNode( widgets: IBaseWidget[] = [], executionId?: string ): LGraphNode { - return { + return fromAny({ id, type, widgets, _testExecutionId: executionId - } as unknown as LGraphNode + }) } /** Helper: create a mock LGraph containing given nodes */ function makeGraph(nodes: LGraphNode[]): LGraph { - return { _testNodes: nodes } as unknown as LGraph + return fromAny({ _testNodes: nodes }) } const noAssetSupport = () => false @@ -390,13 +391,13 @@ describe('scanAllModelCandidates', () => { }) it('skips subgraph container nodes whose promoted widgets are already scanned via interior nodes', () => { - const containerNode = { + const containerNode = fromAny({ id: 65, type: 'abc-def-uuid', widgets: [makeComboWidget('ckpt_name', 'model.safetensors', [])], isSubgraphNode: () => true, _testExecutionId: '65' - } as unknown as LGraphNode + }) const interiorNode = makeNode( 42, @@ -437,7 +438,7 @@ const alwaysInstalled = async () => true describe('enrichWithEmbeddedMetadata', () => { it('enriches existing candidate with url and directory from embedded metadata', async () => { const candidates = [makeCandidate('model_a.safetensors')] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -467,7 +468,7 @@ describe('enrichWithEmbeddedMetadata', () => { hash_type: 'sha256' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, @@ -487,7 +488,7 @@ describe('enrichWithEmbeddedMetadata', () => { url: 'https://existing.com' }) ] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -515,7 +516,7 @@ describe('enrichWithEmbeddedMetadata', () => { directory: 'new_dir' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, @@ -530,7 +531,7 @@ describe('enrichWithEmbeddedMetadata', () => { it('does not mutate the original candidates array', async () => { const candidates = [makeCandidate('model_a.safetensors')] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -558,7 +559,7 @@ describe('enrichWithEmbeddedMetadata', () => { directory: 'checkpoints' } ] - } as unknown as ComfyWorkflowJSON + }) const originalUrl = candidates[0].url await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing) @@ -568,7 +569,7 @@ describe('enrichWithEmbeddedMetadata', () => { it('adds new candidate for embedded model not found by COMBO scan', async () => { const candidates: MissingModelCandidate[] = [] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -596,7 +597,7 @@ describe('enrichWithEmbeddedMetadata', () => { directory: 'checkpoints' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, @@ -611,7 +612,7 @@ describe('enrichWithEmbeddedMetadata', () => { it('does not add candidate when model is already installed', async () => { const candidates: MissingModelCandidate[] = [] - const graphData = { + const graphData = fromPartial({ last_node_id: 0, last_link_id: 0, nodes: [], @@ -627,7 +628,7 @@ describe('enrichWithEmbeddedMetadata', () => { directory: 'checkpoints' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, @@ -662,7 +663,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { // OSS path: candidates start empty, enrichWithEmbeddedMetadata adds // missing embedded models so the dialog can show them. const candidates: MissingModelCandidate[] = [] - const graphData = { + const graphData = fromPartial({ last_node_id: 2, last_link_id: 0, nodes: [ @@ -706,7 +707,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { directory: 'loras' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, @@ -726,7 +727,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { // When isAssetSupported is omitted (OSS), unmatched embedded models // should have isMissing=true (not undefined), enabling the dialog. const candidates: MissingModelCandidate[] = [] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -754,7 +755,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { directory: 'checkpoints' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, @@ -769,7 +770,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => { const candidates: MissingModelCandidate[] = [] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -802,7 +803,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { directory: 'checkpoints' } ] - } as unknown as ComfyWorkflowJSON + }) const selectiveInstallCheck = async (name: string) => name === 'installed_model.safetensors' @@ -821,7 +822,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => { const candidates: MissingModelCandidate[] = [] - const graphData = { + const graphData = fromPartial({ last_node_id: 1, last_link_id: 0, nodes: [ @@ -849,7 +850,7 @@ describe('OSS missing model detection (non-Cloud path)', () => { directory: 'checkpoints' } ] - } as unknown as ComfyWorkflowJSON + }) const result = await enrichWithEmbeddedMetadata( candidates, diff --git a/src/platform/nodeReplacement/cnrIdUtil.test.ts b/src/platform/nodeReplacement/cnrIdUtil.test.ts index 01a29ace68..7e244c4693 100644 --- a/src/platform/nodeReplacement/cnrIdUtil.test.ts +++ b/src/platform/nodeReplacement/cnrIdUtil.test.ts @@ -1,7 +1,7 @@ +import { fromAny } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' - import { getCnrIdFromNode, getCnrIdFromProperties } from './cnrIdUtil' describe('getCnrIdFromProperties', () => { @@ -40,28 +40,28 @@ describe('getCnrIdFromProperties', () => { describe('getCnrIdFromNode', () => { it('returns cnr_id from node properties', () => { - const node = { + const node = fromAny({ properties: { cnr_id: 'node-pack' } - } as unknown as LGraphNode + }) expect(getCnrIdFromNode(node)).toBe('node-pack') }) it('returns aux_id when cnr_id is absent', () => { - const node = { + const node = fromAny({ properties: { aux_id: 'node-aux-pack' } - } as unknown as LGraphNode + }) expect(getCnrIdFromNode(node)).toBe('node-aux-pack') }) it('prefers cnr_id over aux_id in node properties', () => { - const node = { + const node = fromAny({ properties: { cnr_id: 'primary', aux_id: 'secondary' } - } as unknown as LGraphNode + }) expect(getCnrIdFromNode(node)).toBe('primary') }) it('returns undefined when node has no cnr_id or aux_id', () => { - const node = { properties: {} } as unknown as LGraphNode + const node = fromAny({ properties: {} }) expect(getCnrIdFromNode(node)).toBeUndefined() }) }) diff --git a/src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts b/src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts index 4c199ca9bf..69113f6e29 100644 --- a/src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts +++ b/src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts @@ -1,5 +1,6 @@ -import { mount } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' +import { mount } from '@vue/test-utils' import PrimeVue from 'primevue/config' import { describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' @@ -184,9 +185,9 @@ describe('SwapNodeGroupRow', () => { const wrapper = mountRow({ group: makeGroup({ // Intentionally omits nodeId to test graceful handling of incomplete node data - nodeTypes: [ + nodeTypes: fromAny([ { type: 'NoIdNode', isReplaceable: true } - ] as unknown as MissingNodeType[] + ]) }) }) await expand(wrapper) @@ -234,7 +235,7 @@ describe('SwapNodeGroupRow', () => { const wrapper = mountRow({ group: makeGroup({ // Intentionally uses a plain string entry to test legacy node type handling - nodeTypes: ['StringType'] as unknown as MissingNodeType[] + nodeTypes: fromAny(['StringType']) }) }) await wrapper.get('button[aria-label="Expand"]').trigger('click') diff --git a/src/platform/nodeReplacement/missingNodeScan.test.ts b/src/platform/nodeReplacement/missingNodeScan.test.ts index 0652a0a785..25011b1ff8 100644 --- a/src/platform/nodeReplacement/missingNodeScan.test.ts +++ b/src/platform/nodeReplacement/missingNodeScan.test.ts @@ -1,3 +1,4 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -58,16 +59,16 @@ function mockNode( type: string, overrides: Partial = {} ): LGraphNode { - return { + return fromAny({ id, type, last_serialization: { type }, ...overrides - } as unknown as LGraphNode + }) } function mockGraph(): LGraph { - return {} as unknown as LGraph + return fromAny({}) } function getMissingNodesError( @@ -216,9 +217,9 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => { it('uses last_serialization.type over node.type', () => { const node = mockNode(1, 'LiveType') - node.last_serialization = { + node.last_serialization = fromPartial({ type: 'OriginalType' - } as unknown as LGraphNode['last_serialization'] + }) vi.mocked(collectAllNodes).mockReturnValue([node]) vi.mocked(getExecutionIdByNode).mockReturnValue(null) diff --git a/src/platform/nodeReplacement/useNodeReplacement.test.ts b/src/platform/nodeReplacement/useNodeReplacement.test.ts index f3a60f2778..d6be1879e7 100644 --- a/src/platform/nodeReplacement/useNodeReplacement.test.ts +++ b/src/platform/nodeReplacement/useNodeReplacement.test.ts @@ -1,10 +1,11 @@ +import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' -import type { NodeReplacement } from './types' import type { MissingNodeType } from '@/types/comfy' +import type { NodeReplacement } from './types' vi.mock('@/lib/litegraph/src/litegraph', () => ({ LiteGraph: { @@ -79,13 +80,13 @@ function createMockGraph( links: ReturnType[] = [] ): LGraph { const linksMap = new Map(links.map((l) => [l.id, l])) - return { + return fromAny({ _nodes: nodes, _nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])), links: linksMap, updateExecutionOrder: vi.fn(), setDirtyCanvas: vi.fn() - } as unknown as LGraph + }) } function createPlaceholderNode( @@ -95,7 +96,7 @@ function createPlaceholderNode( outputs: { name: string; links: number[] | null }[] = [], graph?: LGraph ): LGraphNode { - return { + return fromAny({ id, type, pos: [100, 200], @@ -131,7 +132,7 @@ function createPlaceholderNode( outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })), widgets_values: [] })) - } as unknown as LGraphNode + }) } function createNewNode( @@ -139,7 +140,7 @@ function createNewNode( outputs: { name: string; links: number[] | null }[] = [], widgets: { name: string; value: unknown }[] = [] ): LGraphNode { - return { + return fromAny({ id: 0, type: '', pos: [0, 0], @@ -153,7 +154,7 @@ function createNewNode( widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })), configure: vi.fn(), serialize: vi.fn() - } as unknown as LGraphNode + }) } function makeMissingNodeType( @@ -756,8 +757,10 @@ describe('useNodeReplacement', () => { it('should exclude nodes without last_serialization', () => { const freshNode = createPlaceholderNode(1, 'OldNode') - freshNode.last_serialization = - undefined as unknown as LGraphNode['last_serialization'] + freshNode.last_serialization = fromAny< + LGraphNode['last_serialization'], + unknown + >(undefined) const graph = createMockGraph([freshNode]) Object.assign(app, { rootGraph: graph }) @@ -780,7 +783,7 @@ describe('useNodeReplacement', () => { it('should fall back to node.type when last_serialization.type is undefined', () => { const node = createPlaceholderNode(1, 'FallbackType') - node.last_serialization!.type = undefined as unknown as string + node.last_serialization!.type = fromAny(undefined) node.type = 'FallbackType' const graph = createMockGraph([node]) Object.assign(app, { rootGraph: graph }) @@ -809,7 +812,7 @@ describe('useNodeReplacement', () => { // targetTypes still holds the original unsanitized name "OldNode&Special", // so the predicate must fall back to checking sanitizeNodeName(originalType). const node = createPlaceholderNode(1, 'OldNodeSpecial') - node.last_serialization!.type = undefined as unknown as string + node.last_serialization!.type = fromAny(undefined) // Simulate what sanitizeNodeName does to '&' in the live type node.type = 'OldNodeSpecial' // '&' already stripped by sanitizeNodeName const graph = createMockGraph([node]) diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts index 3765faf409..64a137d70e 100644 --- a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts +++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts @@ -1,9 +1,10 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { flushPromises, mount } from '@vue/test-utils' -import { createI18n } from 'vue-i18n' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' -import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes' import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue' +import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes' const mockGetSharedWorkflow = vi.fn() @@ -51,9 +52,9 @@ function makePayload( name: 'Test Workflow', listed: true, publishedAt: new Date('2026-02-20T00:00:00Z'), - workflowJson: { + workflowJson: fromPartial({ nodes: [] - } as unknown as SharedWorkflowPayload['workflowJson'], + }), assets: [], ...overrides } diff --git a/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts b/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts index de1afd4ccc..ebaff54b6c 100644 --- a/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts +++ b/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts @@ -1,7 +1,8 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes' import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader' +import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes' const preservedQueryMocks = vi.hoisted(() => ({ clearPreservedQuery: vi.fn(), @@ -107,9 +108,9 @@ function makePayload( name: 'Test Workflow', listed: true, publishedAt: new Date('2026-02-20T00:00:00Z'), - workflowJson: { + workflowJson: fromPartial({ nodes: [] - } as unknown as SharedWorkflowPayload['workflowJson'], + }), assets: [], ...overrides } diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts index cdb003a982..8f88bf9a17 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import fs from 'fs' import { describe, expect, it } from 'vitest' @@ -295,29 +296,33 @@ describe('flattenWorkflowNodes', () => { }) it('includes subgraph nodes with prefixed IDs', () => { - const result = flattenWorkflowNodes({ - nodes: [node(5, 'def-A')], - definitions: { - subgraphs: [ - subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')]) - ] - } - } as unknown as ComfyWorkflowJSON) + const result = flattenWorkflowNodes( + fromPartial({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [ + subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')]) + ] + } + }) + ) expect(result).toHaveLength(3) // 1 root + 2 subgraph expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20']) }) it('prefixes nested subgraph nodes with full execution path', () => { - const result = flattenWorkflowNodes({ - nodes: [node(5, 'def-A')], - definitions: { - subgraphs: [ - subgraphDef('def-A', [node(10, 'def-B')]), - subgraphDef('def-B', [node(3, 'Leaf')]) - ] - } - } as unknown as ComfyWorkflowJSON) + const result = flattenWorkflowNodes( + fromPartial({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [ + subgraphDef('def-A', [node(10, 'def-B')]), + subgraphDef('def-B', [node(3, 'Leaf')]) + ] + } + }) + ) // root:5, def-A inner: 5:10, def-B inner: 5:10:3 expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3']) diff --git a/src/platform/workspace/composables/useCreateWorkspaceUrlLoader.test.ts b/src/platform/workspace/composables/useCreateWorkspaceUrlLoader.test.ts index 573b94e38b..8164fd5dac 100644 --- a/src/platform/workspace/composables/useCreateWorkspaceUrlLoader.test.ts +++ b/src/platform/workspace/composables/useCreateWorkspaceUrlLoader.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useCreateWorkspaceUrlLoader } from './useCreateWorkspaceUrlLoader' @@ -119,7 +120,7 @@ describe('useCreateWorkspaceUrlLoader', () => { it('ignores non-string param', async () => { mockRouteQuery.value = { - create_workspace: ['array'] as unknown as string + create_workspace: fromAny(['array']) } const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader() diff --git a/src/platform/workspace/composables/useInviteUrlLoader.test.ts b/src/platform/workspace/composables/useInviteUrlLoader.test.ts index 0144402a1a..4cf45aeb7e 100644 --- a/src/platform/workspace/composables/useInviteUrlLoader.test.ts +++ b/src/platform/workspace/composables/useInviteUrlLoader.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useInviteUrlLoader } from './useInviteUrlLoader' @@ -224,7 +225,9 @@ describe('useInviteUrlLoader', () => { }) it('ignores non-string invite param', async () => { - mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string } + mockRouteQuery.value = { + invite: fromAny(['array', 'value']) + } const { loadInviteFromUrl } = useInviteUrlLoader() await loadInviteFromUrl() diff --git a/src/renderer/core/canvas/useAutoPan.test.ts b/src/renderer/core/canvas/useAutoPan.test.ts index f7c2bbccc1..b2d5e4a023 100644 --- a/src/renderer/core/canvas/useAutoPan.test.ts +++ b/src/renderer/core/canvas/useAutoPan.test.ts @@ -1,7 +1,7 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DragAndScale } from '@/lib/litegraph/src/DragAndScale' - import { AutoPanController, calculateEdgePanSpeed @@ -74,7 +74,7 @@ describe('AutoPanController', () => { beforeEach(() => { vi.useFakeTimers() - mockCanvas = { + mockCanvas = fromPartial({ getBoundingClientRect: () => ({ left: 0, top: 0, @@ -86,12 +86,9 @@ describe('AutoPanController', () => { y: 0, toJSON: () => {} }) - } as unknown as HTMLCanvasElement + }) - mockDs = { - offset: [0, 0], - scale: 1 - } as unknown as DragAndScale + mockDs = fromPartial({ offset: [0, 0], scale: 1 }) onPanMock = vi.fn<(dx: number, dy: number) => void>() controller = new AutoPanController({ diff --git a/src/renderer/extensions/linearMode/flattenNodeOutput.test.ts b/src/renderer/extensions/linearMode/flattenNodeOutput.test.ts index ec10ff2582..dff34b389a 100644 --- a/src/renderer/extensions/linearMode/flattenNodeOutput.test.ts +++ b/src/renderer/extensions/linearMode/flattenNodeOutput.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput' @@ -84,10 +85,12 @@ describe(flattenNodeOutput, () => { }) it('flattens non-standard output keys with ResultItem-like values', () => { - const output = makeOutput({ - a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }], - b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }] - } as unknown as Partial) + const output = makeOutput( + fromPartial({ + a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }], + b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }] + }) + ) const result = flattenNodeOutput(['10', output]) @@ -109,10 +112,10 @@ describe(flattenNodeOutput, () => { }) it('excludes non-ResultItem array items', () => { - const output = { + const output = fromPartial({ images: [{ filename: 'img.png', subfolder: '', type: 'output' }], custom_data: [{ randomKey: 123 }] - } as unknown as NodeExecutionOutput + }) const result = flattenNodeOutput(['1', output]) @@ -121,12 +124,12 @@ describe(flattenNodeOutput, () => { }) it('accepts items with filename but no subfolder', () => { - const output = { + const output = fromPartial({ images: [ { filename: 'valid.png', subfolder: '', type: 'output' }, { filename: 'no-subfolder.png' } ] - } as unknown as NodeExecutionOutput + }) const result = flattenNodeOutput(['1', output]) @@ -137,12 +140,12 @@ describe(flattenNodeOutput, () => { }) it('excludes items missing filename', () => { - const output = { + const output = fromPartial({ images: [ { filename: 'valid.png', subfolder: '', type: 'output' }, { subfolder: '', type: 'output' } ] - } as unknown as NodeExecutionOutput + }) const result = flattenNodeOutput(['1', output]) diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts index 173ee0f3f3..49bd7a4df6 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { mount } from '@vue/test-utils' import { setActivePinia } from 'pinia' import { nextTick } from 'vue' @@ -8,11 +9,10 @@ import type { SafeWidgetData, VueNodeData } from '@/composables/graph/useGraphNodeManager' +import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue' import { usePromotionStore } from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' -import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue' - vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ canvas: { @@ -79,8 +79,8 @@ describe('NodeWidgets', () => { } const getBorderStyles = (wrapper: ReturnType) => - ( - wrapper.vm as unknown as { processedWidgets: unknown[] } + fromAny<{ processedWidgets: unknown[] }, unknown>( + wrapper.vm ).processedWidgets.map( (entry) => ( diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.autoPan.test.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.autoPan.test.ts index ceec6c7505..c01ddb2ac9 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.autoPan.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.autoPan.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fromPartial } from '@total-typescript/shoehorn' const { capturedOnPan, @@ -205,7 +206,7 @@ function pointerEvent( clientY: number, pointerId = 1 ): PointerEvent { - return { + return fromPartial({ clientX, clientY, button: 0, @@ -217,7 +218,7 @@ function pointerEvent( target: document.createElement('div'), preventDefault: vi.fn(), stopPropagation: vi.fn() - } as unknown as PointerEvent + }) } function startDrag() { diff --git a/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.test.ts b/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.test.ts index 45ba85c1c6..3645006884 100644 --- a/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.test.ts +++ b/src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraph, LGraphExtra } from '@/lib/litegraph/src/LGraph' @@ -35,7 +36,7 @@ function createMockGraph( ): Partial { const graph: Partial = { id: crypto.randomUUID(), - nodes: nodes as unknown as LGraph['nodes'], + nodes: fromAny(nodes), groups: [], reroutes: new Map() as LGraph['reroutes'], extra diff --git a/src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts b/src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts index 30acde6060..079ce78740 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts @@ -1,12 +1,20 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import type { Ref } from 'vue' + import type { NodeLayout } from '@/renderer/core/layout/types' +// TODO: Simplify test setup — use real layoutStore + createTestingPinia instead +// of manually mocking every dependency. See https://github.com/Comfy-Org/ComfyUI_frontend/issues/10765 const testState = vi.hoisted(() => { + // Imports are unavailable inside vi.hoisted() so shoehorn's fromAny cannot + // be used here. This local identity function serves the same purpose + // (runtime no-op cast) until the test is rewritten to use real stores. + const placeholder = (v: unknown): T => v as T return { - selectedNodeIds: null as unknown as Ref>, - selectedItems: null as unknown as Ref, + selectedNodeIds: placeholder>>(null), + selectedItems: placeholder>(null), nodeLayouts: new Map>(), mutationFns: { setSource: vi.fn(), @@ -114,12 +122,7 @@ function pointerEvent(clientX: number, clientY: number): PointerEvent { const target = document.createElement('div') target.hasPointerCapture = vi.fn(() => false) target.setPointerCapture = vi.fn() - return { - clientX, - clientY, - target, - pointerId: 1 - } as unknown as PointerEvent + return fromPartial({ clientX, clientY, target, pointerId: 1 }) } describe('useNodeDrag', () => { diff --git a/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts b/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts index ef1657073c..5d0a5add31 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts @@ -1,11 +1,11 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' import { createI18n } from 'vue-i18n' import type { SimplifiedWidget } from '@/types/simplifiedWidget' - import DisplayCarousel from './DisplayCarousel.vue' import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue' import { createMockWidget } from './widgetTestUtils' @@ -124,7 +124,10 @@ describe('DisplayCarousel Single Mode', () => { it('handles null value gracefully', () => { const widget = createGalleriaWidget([]) - const wrapper = mountComponent(widget, null as unknown as GalleryValue) + const wrapper = mountComponent( + widget, + fromAny(null) + ) expect(wrapper.find('img').exists()).toBe(false) }) @@ -133,7 +136,7 @@ describe('DisplayCarousel Single Mode', () => { const widget = createGalleriaWidget([]) const wrapper = mountComponent( widget, - undefined as unknown as GalleryValue + fromAny(undefined) ) expect(wrapper.find('img').exists()).toBe(false) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts index c703e86cef..208feded85 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import PrimeVue from 'primevue/config' @@ -9,10 +10,9 @@ import { createI18n } from 'vue-i18n' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types' +import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue' import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { SimplifiedWidget } from '@/types/simplifiedWidget' - -import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue' import { createMockWidget } from './widgetTestUtils' const mockCheckState = vi.hoisted(() => vi.fn()) @@ -121,18 +121,20 @@ describe('WidgetSelectDropdown custom label mapping', () => { modelValue: string | undefined, assetKind: 'image' | 'video' | 'audio' = 'image' ): VueWrapper => { - return mount(WidgetSelectDropdown, { - props: { - widget, - modelValue, - assetKind, - allowUpload: true, - uploadFolder: 'input' - }, - global: { - plugins: [PrimeVue, createTestingPinia(), i18n] - } - }) as unknown as VueWrapper + return fromAny, unknown>( + mount(WidgetSelectDropdown, { + props: { + widget, + modelValue, + assetKind, + allowUpload: true, + uploadFolder: 'input' + }, + global: { + plugins: [PrimeVue, createTestingPinia(), i18n] + } + }) + ) } describe('when custom labels are not provided', () => { @@ -258,7 +260,7 @@ describe('WidgetSelectDropdown custom label mapping', () => { it('falls back to original value when label mapping returns undefined', () => { const getOptionLabel = vi.fn((value?: string | null) => { if (value === 'hash789.png') { - return undefined as unknown as string + return fromAny(undefined) } return `Labeled: ${value}` }) @@ -365,7 +367,7 @@ describe('WidgetSelectDropdown custom label mapping', () => { it('does not create a fallback item when modelValue is undefined', () => { const widget = createSelectDropdownWidget( - undefined as unknown as string, + fromAny(undefined), { values: ['img_001.png', 'photo_abc.jpg'] } @@ -415,18 +417,20 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => { widget: SimplifiedWidget, modelValue: string | undefined ): VueWrapper => { - return mount(WidgetSelectDropdown, { - props: { - widget, - modelValue, - assetKind: 'model', - isAssetMode: true, - nodeType: 'CheckpointLoaderSimple' - }, - global: { - plugins: [PrimeVue, createTestingPinia(), i18n] - } - }) as unknown as VueWrapper + return fromAny, unknown>( + mount(WidgetSelectDropdown, { + props: { + widget, + modelValue, + assetKind: 'model', + isAssetMode: true, + nodeType: 'CheckpointLoaderSimple' + }, + global: { + plugins: [PrimeVue, createTestingPinia(), i18n] + } + }) + ) } beforeEach(() => { @@ -549,10 +553,12 @@ describe('WidgetSelectDropdown multi-output jobs', () => { widget: SimplifiedWidget, modelValue: string | undefined ): VueWrapper { - return mount(WidgetSelectDropdown, { - props: { widget, modelValue, assetKind: 'image' as const }, - global: { plugins: [PrimeVue, createTestingPinia(), i18n] } - }) as unknown as VueWrapper + return fromAny, unknown>( + mount(WidgetSelectDropdown, { + props: { widget, modelValue, assetKind: 'image' as const }, + global: { plugins: [PrimeVue, createTestingPinia(), i18n] } + }) + ) } const defaultWidget = () => @@ -744,18 +750,20 @@ describe('WidgetSelectDropdown undo tracking', () => { widget: SimplifiedWidget, modelValue: string | undefined ): VueWrapper => { - return mount(WidgetSelectDropdown, { - props: { - widget, - modelValue, - assetKind: 'image', - allowUpload: true, - uploadFolder: 'input' - }, - global: { - plugins: [PrimeVue, createTestingPinia(), i18n] - } - }) as unknown as VueWrapper + return fromAny, unknown>( + mount(WidgetSelectDropdown, { + props: { + widget, + modelValue, + assetKind: 'image', + allowUpload: true, + uploadFolder: 'input' + }, + global: { + plugins: [PrimeVue, createTestingPinia(), i18n] + } + }) + ) } beforeEach(() => { diff --git a/src/renderer/glsl/useGLSLPreview.test.ts b/src/renderer/glsl/useGLSLPreview.test.ts index 3029c21761..8403af7ba1 100644 --- a/src/renderer/glsl/useGLSLPreview.test.ts +++ b/src/renderer/glsl/useGLSLPreview.test.ts @@ -1,13 +1,17 @@ +import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, reactive, ref, shallowRef } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer' import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview' import { useWidgetValueStore } from '@/stores/widgetValueStore' -import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer' -import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' -import type { MaybeRefOrGetter } from 'vue' +type WidgetValueStoreStub = { + _widgetMap: Map +} const mockRendererFactory = vi.hoisted(() => { const init = vi.fn(() => true) @@ -99,7 +103,7 @@ vi.mock('@/utils/objectUrlUtil', () => ({ function createMockNode(overrides: Record = {}): LGraphNode { const graph = { id: 'test-graph-id', rootGraph: { id: 'test-graph-id' } } - return { + return fromAny({ id: 1, type: 'GLSLShader', inputs: [], @@ -107,7 +111,7 @@ function createMockNode(overrides: Record = {}): LGraphNode { getInputNode: vi.fn(() => null), isSubgraphNode: () => false, ...overrides - } as unknown as LGraphNode + }) } function wrapNode( @@ -177,9 +181,9 @@ describe('useGLSLPreview', () => { mockNodeOutputs[String(node.id)] = { images: [{ filename: 'test.png', subfolder: '', type: 'temp' }] } - const store = useWidgetValueStore() as unknown as { - _widgetMap: Map - } + const store = fromAny( + useWidgetValueStore() + ) store._widgetMap.set('fragment_shader', { value: 'void main() {}' }) @@ -241,9 +245,9 @@ describe('useGLSLPreview', () => { mockNodeOutputs[String(node.id)] = { images: [{ filename: 'test.png', subfolder: '', type: 'temp' }] } - const store = useWidgetValueStore() as unknown as { - _widgetMap: Map - } + const store = fromAny( + useWidgetValueStore() + ) store._widgetMap.set('fragment_shader', { value: 'void main() {}' }) @@ -299,9 +303,9 @@ describe('useGLSLPreview', () => { }) it('skips render when shader source is unavailable', async () => { - const store = useWidgetValueStore() as unknown as { - _widgetMap: Map - } + const store = fromAny( + useWidgetValueStore() + ) store._widgetMap.delete('fragment_shader') const node = createMockNode() diff --git a/src/stores/appModeStore.test.ts b/src/stores/appModeStore.test.ts index 6ce6f697ff..60e4a445e4 100644 --- a/src/stores/appModeStore.test.ts +++ b/src/stores/appModeStore.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { nextTick } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -195,25 +196,27 @@ describe('appModeStore', () => { outputs: number[] ) { const workflow = createBuilderWorkflow('app') - workflow.changeTracker = createMockChangeTracker({ - activeState: { - last_node_id: 0, - last_link_id: 0, - nodes: [], - links: [], - groups: [], - config: {}, - version: 0.4, - extra: { linearData: { inputs, outputs } } - } - } as unknown as Partial) + workflow.changeTracker = createMockChangeTracker( + fromPartial>({ + activeState: { + last_node_id: 0, + last_link_id: 0, + nodes: [], + links: [], + groups: [], + config: {}, + version: 0.4, + extra: { linearData: { inputs, outputs } } + } + }) + ) return workflow } it('removes inputs referencing deleted nodes on load', async () => { const node1 = mockNode(1) mockResolveNode.mockImplementation((id) => - id == 1 ? (node1 as unknown as LGraphNode) : undefined + id == 1 ? fromAny(node1) : undefined ) store.loadSelections({ @@ -229,7 +232,7 @@ describe('appModeStore', () => { it('keeps inputs for existing nodes even if widget is missing', async () => { const node1 = mockNode(1) mockResolveNode.mockImplementation((id) => - id == 1 ? (node1 as unknown as LGraphNode) : undefined + id == 1 ? fromAny(node1) : undefined ) store.loadSelections({ @@ -248,7 +251,7 @@ describe('appModeStore', () => { it('removes outputs referencing deleted nodes on load', async () => { const node1 = mockNode(1) mockResolveNode.mockImplementation((id) => - id == 1 ? (node1 as unknown as LGraphNode) : undefined + id == 1 ? fromAny(node1) : undefined ) store.loadSelections({ outputs: [1, 99] }) @@ -271,7 +274,7 @@ describe('appModeStore', () => { // After graph configures, nodes become resolvable mockResolveNode.mockImplementation((id) => - id == 1 ? (node1 as unknown as LGraphNode) : undefined + id == 1 ? fromAny(node1) : undefined ) ;(app.rootGraph.events as EventTarget).dispatchEvent( new Event('configured') diff --git a/src/stores/executionErrorStore.test.ts b/src/stores/executionErrorStore.test.ts index 10af3710a1..587118cf19 100644 --- a/src/stores/executionErrorStore.test.ts +++ b/src/stores/executionErrorStore.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -391,9 +392,9 @@ describe('clearAllErrors', () => { class_type: 'Test' } } - missingNodesStore.setMissingNodeTypes([ - { type: 'MissingNode', hint: '' } - ] as unknown as MissingNodeType[]) + missingNodesStore.setMissingNodeTypes( + fromAny([{ type: 'MissingNode', hint: '' }]) + ) executionErrorStore.showErrorOverlay() executionErrorStore.clearAllErrors() diff --git a/src/stores/nodeOutputStore.test.ts b/src/stores/nodeOutputStore.test.ts index 8745cafb4f..c1ad6705d2 100644 --- a/src/stores/nodeOutputStore.test.ts +++ b/src/stores/nodeOutputStore.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,11 +32,11 @@ vi.mock('@/scripts/app', () => ({ })) const createMockNode = (overrides: Record = {}): LGraphNode => - ({ + fromAny>({ id: 1, type: 'TestNode', ...overrides - }) as Partial as LGraphNode + }) const createMockOutputs = ( images?: ExecutedWsMessage['output']['images'] @@ -623,7 +624,7 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => { it('should return early for null node', () => { const store = useNodeOutputStore() - store.setNodeOutputs(null as unknown as LGraphNode, 'test.png') + store.setNodeOutputs(fromAny(null), 'test.png') expect(Object.keys(store.nodeOutputs)).toHaveLength(0) }) diff --git a/src/stores/queueStore.loadWorkflow.test.ts b/src/stores/queueStore.loadWorkflow.test.ts index 965c16127c..313681a700 100644 --- a/src/stores/queueStore.loadWorkflow.test.ts +++ b/src/stores/queueStore.loadWorkflow.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,8 +9,8 @@ import type { } from '@/platform/remote/comfyui/jobs/jobTypes' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyApp } from '@/scripts/app' -import { TaskItemImpl } from '@/stores/queueStore' import * as jobOutputCache from '@/services/jobOutputCache' +import { TaskItemImpl } from '@/stores/queueStore' vi.mock('@/services/extensionService', () => ({ useExtensionService: vi.fn(() => ({ @@ -76,13 +77,13 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => { vi.clearAllMocks() mockFetchApi = vi.fn() - mockApp = { + mockApp = fromPartial({ loadGraphData: vi.fn(), nodeOutputs: {}, api: { fetchApi: mockFetchApi } - } as unknown as ComfyApp + }) }) it('should fetch workflow from API for history tasks', async () => { diff --git a/src/stores/resultItemParsing.test.ts b/src/stores/resultItemParsing.test.ts index 6a7b8deca2..fa3a29f55f 100644 --- a/src/stores/resultItemParsing.test.ts +++ b/src/stores/resultItemParsing.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { NodeExecutionOutput } from '@/schemas/apiSchema' @@ -108,10 +109,10 @@ describe(parseNodeOutput, () => { }) it('excludes non-ResultItem array items', () => { - const output = { + const output = fromPartial({ images: [{ filename: 'img.png', subfolder: '', type: 'output' }], custom_data: [{ randomKey: 123 }] - } as unknown as NodeExecutionOutput + }) const result = parseNodeOutput('1', output) @@ -120,12 +121,12 @@ describe(parseNodeOutput, () => { }) it('accepts items with filename but no subfolder', () => { - const output = { + const output = fromPartial({ images: [ { filename: 'valid.png', subfolder: '', type: 'output' }, { filename: 'no-subfolder.png' } ] - } as unknown as NodeExecutionOutput + }) const result = parseNodeOutput('1', output) @@ -136,12 +137,12 @@ describe(parseNodeOutput, () => { }) it('excludes items missing filename', () => { - const output = { + const output = fromPartial({ images: [ { filename: 'valid.png', subfolder: '', type: 'output' }, { subfolder: '', type: 'output' } ] - } as unknown as NodeExecutionOutput + }) const result = parseNodeOutput('1', output) diff --git a/src/stores/subgraphNavigationStore.test.ts b/src/stores/subgraphNavigationStore.test.ts index 1770b40b4c..3eb3c24839 100644 --- a/src/stores/subgraphNavigationStore.test.ts +++ b/src/stores/subgraphNavigationStore.test.ts @@ -1,15 +1,15 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' -import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import type { Subgraph } from '@/lib/litegraph/src/LGraph' import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { app } from '@/scripts/app' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' -import type { Subgraph } from '@/lib/litegraph/src/LGraph' - type MockSubgraph = Pick function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph { @@ -20,7 +20,7 @@ function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph { nodes: [] } satisfies MockSubgraph - return mockSubgraph as unknown as Subgraph + return fromAny(mockSubgraph) } vi.mock('@/scripts/app', () => { diff --git a/src/stores/subgraphStore.test.ts b/src/stores/subgraphStore.test.ts index 99e3078775..4b37706fd6 100644 --- a/src/stores/subgraphStore.test.ts +++ b/src/stores/subgraphStore.test.ts @@ -1,22 +1,21 @@ +import { createTestingPinia } from '@pinia/testing' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema' -import type { GlobalSubgraphData } from '@/scripts/api' -import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation' -import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template' -import { api } from '@/scripts/api' -import { app as comfyApp } from '@/scripts/app' -import { useNodeDefStore } from '@/stores/nodeDefStore' -import { useSubgraphStore } from '@/stores/subgraphStore' - -import { useLitegraphService } from '@/services/litegraphService' - import { createTestSubgraph, createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' -import { createTestingPinia } from '@pinia/testing' +import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation' +import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template' +import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema' +import type { GlobalSubgraphData } from '@/scripts/api' +import { api } from '@/scripts/api' +import { app as comfyApp } from '@/scripts/app' +import { useLitegraphService } from '@/services/litegraphService' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { useSubgraphStore } from '@/stores/subgraphStore' const mockDistributionTypes = vi.hoisted(() => ({ isCloud: false, @@ -108,12 +107,12 @@ describe('useSubgraphStore', () => { graph.add(subgraphNode) vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => { - const serializedSubgraph = { + const serializedSubgraph = fromPartial({ ...subgraph.serialize(), links: [], groups: [], version: 1 - } as Partial as ExportedSubgraph + }) return { nodes: [subgraphNode.serialize()], subgraphs: [serializedSubgraph] @@ -264,7 +263,9 @@ describe('useSubgraphStore', () => { failing_blueprint: { name: 'Failing Blueprint', info: { node_pack: 'test_pack' }, - data: Promise.reject(new Error('Network error')) as unknown as string + data: fromAny( + Promise.reject(new Error('Network error')) + ) } } ) @@ -389,12 +390,12 @@ describe('useSubgraphStore', () => { vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => { - const serializedSubgraph = { + const serializedSubgraph = fromPartial({ ...subgraph.serialize(), links: [], groups: [], version: 1 - } as Partial as ExportedSubgraph + }) return { nodes: [subgraphNode.serialize()], subgraphs: [serializedSubgraph] diff --git a/src/utils/nodeDefUtil.test.ts b/src/utils/nodeDefUtil.test.ts index 749c818d20..a242cd5ecc 100644 --- a/src/utils/nodeDefUtil.test.ts +++ b/src/utils/nodeDefUtil.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { @@ -175,7 +176,10 @@ describe('nodeDefUtil', () => { const spec1: IntInputSpec = ['INT', { min: 0, max: 10 }] const spec2: ComboInputSpecV2 = ['COMBO', { options: ['A', 'B'] }] - const result = mergeInputSpec(spec1, spec2 as unknown as IntInputSpec) + const result = mergeInputSpec( + spec1, + fromAny(spec2) + ) expect(result).toBeNull() }) diff --git a/src/utils/widgetUtil.test.ts b/src/utils/widgetUtil.test.ts index f3fa12a0f7..d74aa3b67e 100644 --- a/src/utils/widgetUtil.test.ts +++ b/src/utils/widgetUtil.test.ts @@ -1,10 +1,10 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' - import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil' vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({ @@ -50,14 +50,14 @@ describe('getWidgetDefaultValue', () => { }) function makeWidget(overrides: Record = {}): IBaseWidget { - return { + return fromAny({ name: 'myWidget', type: 'number', value: 0, label: undefined, options: {}, ...overrides - } as unknown as IBaseWidget + }) } function makeNode({ @@ -67,11 +67,11 @@ function makeNode({ isSubgraph?: boolean inputs?: INodeInputSlot[] } = {}): LGraphNode { - return { + return fromAny({ id: 1, inputs, isSubgraphNode: () => isSubgraph - } as unknown as LGraphNode + }) } describe('renameWidget', () => { @@ -131,11 +131,11 @@ describe('renameWidget', () => { it('updates _subgraphSlot.label when input has a subgraph slot', () => { const widget = makeWidget({ name: 'seed' }) const subgraphSlot = { label: undefined as string | undefined } - const input = { + const input = fromAny({ name: 'seed', widget: { name: 'seed' }, _subgraphSlot: subgraphSlot - } as unknown as INodeInputSlot + }) const node = makeNode({ inputs: [input] }) renameWidget(widget, node, 'New Label') diff --git a/src/workbench/extensions/manager/utils/graphHasMissingNodes.test.ts b/src/workbench/extensions/manager/utils/graphHasMissingNodes.test.ts index d987e72310..33c16cadb6 100644 --- a/src/workbench/extensions/manager/utils/graphHasMissingNodes.test.ts +++ b/src/workbench/extensions/manager/utils/graphHasMissingNodes.test.ts @@ -1,3 +1,4 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { @@ -5,12 +6,12 @@ import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { collectMissingNodes, graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes' import type { NodeDefLookup } from '@/workbench/extensions/manager/utils/graphHasMissingNodes' -import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' type NodeDefs = NodeDefLookup @@ -18,23 +19,23 @@ let nodeIdCounter = 0 const mockNodeDef = {} as ComfyNodeDefImpl const createGraph = (nodes: LGraphNode[] = []): LGraph => { - return { nodes } as Partial as LGraph + return fromPartial({ nodes }) } const createSubgraph = (nodes: LGraphNode[]): Subgraph => { - return { nodes } as Partial as Subgraph + return fromPartial({ nodes }) } const createNode = ( type?: string, subgraphNodes?: LGraphNode[] ): LGraphNode => { - return { + return fromAny({ id: nodeIdCounter++, type, isSubgraphNode: subgraphNodes ? () => true : undefined, subgraph: subgraphNodes ? createSubgraph(subgraphNodes) : undefined - } as unknown as LGraphNode + }) } describe('graphHasMissingNodes', () => { From 61049425a39e384acd5e0d8bf6a4bcb0b1c40c0e Mon Sep 17 00:00:00 2001 From: Dante Date: Tue, 31 Mar 2026 12:17:24 +0900 Subject: [PATCH 013/218] fix(DisplayCarousel): use back button in grid view and remove hover icons (#10655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Grid view top-left icon changed from square to back arrow (`arrow-left`) per Figma spec - Back button is always visible in grid view (no longer hover-dependent), uses sticky positioning - Removed hover opacity effect on grid thumbnails ## Related - Figma: https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=6008-83034&m=dev - Figma: https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=6008-83069&m=dev ## Test plan - [x] All 31 existing DisplayCarousel tests pass - [ ] Visual check: grid view shows back arrow icon (top-left, always visible) - [ ] Visual check: hovering grid thumbnails shows no overlay icons - [ ] Verify back button stays visible when scrolling through many grid items ## Screenshot ### Before 스크린샷 2026-03-28 오후 4 31 54 스크린샷 2026-03-28 오후 4 32 03 ### After 스크린샷 2026-03-28 오후 4 31 43 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10655-fix-DisplayCarousel-use-back-button-in-grid-view-and-remove-hover-icons-3316d73d365081c5826afd63c50994ba) by [Unito](https://www.unito.io) --- .../components/DisplayCarousel.test.ts | 70 ++++++++++++++++--- .../widgets/components/DisplayCarousel.vue | 32 +-------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts b/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts index 5d0a5add31..2ddfa678f3 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.test.ts @@ -341,7 +341,7 @@ describe('DisplayCarousel Grid Mode', () => { ) }) - it('switches back to single mode via toggle button', async () => { + it('grid mode has no overlay icons', async () => { const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL]) // Switch to grid via focus on image container @@ -350,19 +350,69 @@ describe('DisplayCarousel Grid Mode', () => { await wrapper.find('[aria-label="Switch to grid view"]').trigger('click') await nextTick() - // Focus the grid container to reveal toggle + // Grid mode should have no toggle/back button + expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe( + false + ) + expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe( + false + ) + }) + + it('always uses undo-2 icon for grid toggle button', async () => { + const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL]) + + // Show controls await findImageContainer(wrapper).trigger('focusin') await nextTick() - // Switch back to single - const singleToggle = wrapper.find('[aria-label="Switch to single view"]') - expect(singleToggle.exists()).toBe(true) + const toggleBtn = wrapper.find('[aria-label="Switch to grid view"]') + expect(toggleBtn.find('i').classes()).toContain('icon-[lucide--undo-2]') - await singleToggle.trigger('click') + // Switch to grid and back + await toggleBtn.trigger('click') await nextTick() - // Should be back in single mode with main image - expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true) + const gridButtons = wrapper + .findAll('button') + .filter((btn) => btn.find('img').exists()) + await gridButtons[0].trigger('click') + await nextTick() + + await findImageContainer(wrapper).trigger('focusin') + await nextTick() + + // Icon should still be undo-2 + const toggleBtnAfter = wrapper.find('[aria-label="Switch to grid view"]') + expect(toggleBtnAfter.find('i').classes()).toContain( + 'icon-[lucide--undo-2]' + ) + }) + + it('shows grid button in single mode after selecting from grid', async () => { + const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL]) + + // Switch to grid + await findImageContainer(wrapper).trigger('focusin') + await nextTick() + await wrapper.find('[aria-label="Switch to grid view"]').trigger('click') + await nextTick() + + // Click first grid image to go back to single mode + const gridButtons = wrapper + .findAll('button') + .filter((btn) => btn.find('img').exists()) + await gridButtons[0].trigger('click') + await nextTick() + + // Hover to reveal controls + await findImageContainer(wrapper).trigger('focusin') + await nextTick() + + // Should still show grid view button (same icon always) + expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe( + true + ) }) it('clicking grid image switches to single mode focused on that image', async () => { @@ -404,8 +454,8 @@ describe('DisplayCarousel Grid Mode', () => { await wrapper.setProps({ modelValue: [TEST_IMAGES_SMALL[0]] }) await nextTick() - // Should revert to single mode (no grid toggle visible) - expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe( + // Should revert to single mode (single image, no grid button) + expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe( false ) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.vue b/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.vue index f2eb472352..22c5746551 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/DisplayCarousel.vue @@ -36,7 +36,7 @@ :aria-label="t('g.switchToGridView')" @click="switchToGrid" > - + @@ -142,41 +142,19 @@ ref="gridContainerEl" class="relative h-72 overflow-x-hidden overflow-y-auto rounded-sm bg-component-node-background" tabindex="0" - @mouseenter="isHovered = true" - @mouseleave="isHovered = false" - @focusin="isFocused = true" - @focusout="handleFocusOut" > - - -
@@ -229,7 +207,6 @@ const activeIndex = ref(0) const displayMode = ref('single') const isHovered = ref(false) const isFocused = ref(false) -const hoveredGridIndex = ref(-1) const imageDimensions = ref(null) const thumbnailRefs = ref<(HTMLElement | null)[]>([]) const imageContainerEl = ref() @@ -359,11 +336,6 @@ function switchToGrid() { displayMode.value = 'grid' } -function switchToSingle() { - isHovered.value = false - displayMode.value = 'single' -} - function selectFromGrid(index: number) { activeIndex.value = index imageDimensions.value = null From 515f234143609b054a0e50b06ffe53c770b20114 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:47:27 +0100 Subject: [PATCH 014/218] fix: Ensure all save/save as buttons are the same width (#10681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Makes the save/save as buttons in the builder footer toolbar all a fixed size so when switching states the elements dont jump ## Changes - **What**: - Apply widths from design to the buttons - Add tests that measure the sizes of the buttons ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10681-fix-Ensure-all-save-save-as-buttons-are-the-same-width-3316d73d36508187bb74c5a977ea876f) by [Unito](https://www.unito.io) --- .../fixtures/helpers/BuilderFooterHelper.ts | 4 + browser_tests/fixtures/selectors.ts | 1 + browser_tests/tests/builderSaveFlow.spec.ts | 35 +++++ .../builder/BuilderFooterToolbar.vue | 147 ++++++++++-------- 4 files changed, 120 insertions(+), 67 deletions(-) diff --git a/browser_tests/fixtures/helpers/BuilderFooterHelper.ts b/browser_tests/fixtures/helpers/BuilderFooterHelper.ts index b9edbd3fd1..9ab0549199 100644 --- a/browser_tests/fixtures/helpers/BuilderFooterHelper.ts +++ b/browser_tests/fixtures/helpers/BuilderFooterHelper.ts @@ -30,6 +30,10 @@ export class BuilderFooterHelper { return this.page.getByTestId(TestIds.builder.saveButton) } + get saveGroup(): Locator { + return this.page.getByTestId(TestIds.builder.saveGroup) + } + get saveAsButton(): Locator { return this.page.getByTestId(TestIds.builder.saveAsButton) } diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 0bc7901c62..e974afa8fb 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -82,6 +82,7 @@ export const TestIds = { footerNav: 'builder-footer-nav', saveButton: 'builder-save-button', saveAsButton: 'builder-save-as-button', + saveGroup: 'builder-save-group', saveAsChevron: 'builder-save-as-chevron', ioItem: 'builder-io-item', ioItemTitle: 'builder-io-item-title', diff --git a/browser_tests/tests/builderSaveFlow.spec.ts b/browser_tests/tests/builderSaveFlow.spec.ts index 8d7494e8e9..d9d352ba27 100644 --- a/browser_tests/tests/builderSaveFlow.spec.ts +++ b/browser_tests/tests/builderSaveFlow.spec.ts @@ -189,6 +189,41 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => { await expect(saveAs.nameInput).toBeVisible() }) + test('Save button width is consistent across all states', async ({ + comfyPage + }) => { + const { appMode } = comfyPage + await comfyPage.workflow.loadWorkflow('default') + await fitToViewInstant(comfyPage) + await appMode.enterBuilder() + + // State 1: Disabled "Save as" (no outputs selected) + const disabledBox = await appMode.footer.saveAsButton.boundingBox() + expect(disabledBox).toBeTruthy() + + // Select I/O to enable the button + await appMode.steps.goToInputs() + const ksampler = await comfyPage.nodeOps.getNodeRefById('3') + await appMode.select.selectInputWidget(ksampler) + await appMode.steps.goToOutputs() + await appMode.select.selectOutputNode() + + // State 2: Enabled "Save as" (unsaved, has outputs) + const enabledBox = await appMode.footer.saveAsButton.boundingBox() + expect(enabledBox).toBeTruthy() + expect(enabledBox!.width).toBe(disabledBox!.width) + + // Save the workflow to transition to the Save + chevron state + await builderSaveAs(appMode, `${Date.now()} width-test`, 'App') + await appMode.saveAs.closeButton.click() + await comfyPage.nextFrame() + + // State 3: Save + chevron button group (saved workflow) + const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox() + expect(saveButtonGroupBox).toBeTruthy() + expect(saveButtonGroupBox!.width).toBe(disabledBox!.width) + }) + test('Connect output popover appears when no outputs selected', async ({ comfyPage }) => { diff --git a/src/components/builder/BuilderFooterToolbar.vue b/src/components/builder/BuilderFooterToolbar.vue index 3502adcda4..bb090680c5 100644 --- a/src/components/builder/BuilderFooterToolbar.vue +++ b/src/components/builder/BuilderFooterToolbar.vue @@ -33,76 +33,91 @@ {{ t('g.next') }}