Compare commits

..

18 Commits

Author SHA1 Message Date
jaeone94
b52828c25d fix: backport shared workflow asset import timing
Backport #12333 to cloud/1.44.

Resolve the templates.spec conflict and adapt the browser regression test to the 1.44 ComfyPage fixture API.
2026-05-20 13:30:36 +09:00
Dante
e6a751f42f [backport cloud/1.44] fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006) (#12353)
Backport of #12006 to cloud/1.44.

## Conflict resolution

One conflict in
`src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue`
(imports + dropdown source). On main, the original PR replaced
`useMediaAssets('output')` with `isCloud ? useFlatOutputAssets() :
useAssetsApi('output')`. On `cloud/1.44`, the local path still goes
through `useMediaAssets`, which itself internally gates `isCloud →
useAssetsApi : useInternalFilesApi`. Resolution preserves the new cloud
branch (which is the whole point of this PR — single
`getAssetsByTag('output')` instead of jobs-walk + per-job expansion)
while keeping `useMediaAssets('output')` for the local path on this
branch:

```ts
const outputMediaAssets = isCloud
  ? useFlatOutputAssets()
  : useMediaAssets('output')
```

All other files auto-merged.

## Verification

- `pnpm typecheck` 
- `pnpm test:unit` — `assetsStore.test.ts` (60) +
`useWidgetSelectItems.test.ts` (40) = 100/100 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12353-backport-cloud-1-44-fix-stabilize-multi-output-expansion-simplify-cloud-output-fetc-3666d73d3650815280a4f9207332e058)
by [Unito](https://www.unito.io)
2026-05-20 12:01:15 +09:00
Comfy Org PR Bot
f8a3f462b7 [backport cloud/1.44] Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12346)
Backport of #12344 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12346-backport-cloud-1-44-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-m-3656d73d365081a2bd91ce5002af5fdc)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-05-20 10:26:06 +09:00
Comfy Org PR Bot
81229466e1 [backport cloud/1.44] fix: keep node context menu overflow visible when content fits (#12338)
Backport of #12035 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12338-backport-cloud-1-44-fix-keep-node-context-menu-overflow-visible-when-content-fits-3656d73d3650811aa95bc47b0e41fb4e)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-19 22:21:39 +09:00
Comfy Org PR Bot
db2d381c89 [backport cloud/1.44] fix(cloud): stop bouncing working users to /cloud/survey mid-session (#12320)
Backport of #12301 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12320-backport-cloud-1-44-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-sessi-3646d73d3650815185cecaa2babdf3d1)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 19:50:13 -07:00
Comfy Org PR Bot
13894beeb2 [backport cloud/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12291)
Backport of #12052 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12291-backport-cloud-1-44-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3616d73d365081f3a362e618ab12d34d)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 12:12:41 +09:00
Comfy Org PR Bot
4c4b85bd49 [backport cloud/1.44] fix: include share_id when importing published assets (FE-603) (#12256)
Backport of #12055 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12256-backport-cloud-1-44-fix-include-share_id-when-importing-published-assets-FE-603-3606d73d365081d4965fcec791a68bd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 12:11:55 +09:00
Comfy Org PR Bot
1dffc948d7 [backport cloud/1.44] Fix descriptions on core blueprints (#12259)
Backport of #12220 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12259-backport-cloud-1-44-Fix-descriptions-on-core-blueprints-3606d73d3650811f9956f4368d9e723f)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-13 23:51:19 -07:00
Comfy Org PR Bot
b30454ac4f [backport cloud/1.44] fix: clear media upload errors via widget change (#12245)
Backport of #12212 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12245-backport-cloud-1-44-fix-clear-media-upload-errors-via-widget-change-3606d73d365081bb948cd1d6729565bd)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-14 12:22:26 +09:00
Comfy Org PR Bot
b1b6394345 [backport cloud/1.44] fix: open node info panel from context menu (#12248)
Backport of #12205 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12248-backport-cloud-1-44-fix-open-node-info-panel-from-context-menu-3606d73d36508184b212c72f0af6af26)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 12:22:18 +09:00
Comfy Org PR Bot
efde624926 [backport cloud/1.44] fix: Load Image preview retains deleted asset (FE-230) (#12130)
Backport of #11493 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12130-backport-cloud-1-44-fix-Load-Image-preview-retains-deleted-asset-FE-230-35d6d73d3650812983cfdd3086dc6969)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-14 11:21:41 +09:00
jaeone94
2f48694192 [backport cloud/1.44] fix: suppress missing media scan during uploads (#12111) (#12189)
## Summary

Manual backport of #12111 to `cloud/1.44`.

This suppresses false-positive missing media detection while media
loader nodes are still uploading files from drag/drop, paste, or
file-select flows.

## Conflict Resolution

The cherry-pick conflicted only in
`src/platform/missingMedia/missingMediaScan.test.ts` because the target
branch still has the older annotated-media parameterized test block
around the insertion point. I resolved it by:

- adding the new upload-state tests from #12111 above the existing
annotated-media cases
- keeping the existing release-branch annotated-media `it.each` cases
intact
- using `it.for([false, true])` only for the new upload-state test added
by #12111

## Validation

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
src/platform/missingMedia/missingMediaScan.test.ts
src/composables/node/useNodeImageUpload.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/graph/useErrorClearingHooks.test.ts`

Result: 4 files passed, 87 tests passed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12189-backport-cloud-1-44-fix-suppress-missing-media-scan-during-uploads-12111-35e6d73d36508195a407f1fa0d6898e7)
by [Unito](https://www.unito.io)
2026-05-12 20:38:01 +09:00
Dante
c6d9a84aac [backport cloud/1.44] fix(i18n): clamp unsupported browser locales to a shipped tag (#11712) (#12179)
*PR Created by the Glary-Bot Agent*

---

Backport of #11712 to `cloud/1.44`. Companion to #12178 (`core/1.44`).

## Summary

Cherry-picks `ceb993605` ("fix(i18n): clamp unsupported browser locales
to a shipped tag") onto `cloud/1.44`.

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

The i18n files this PR touches are byte-identical between `core/1.44`
and `cloud/1.44`, so this backport applies the same patch as the core
backport (verified via `git show` byte diff).

## Cherry-pick conflict resolution

One file required manual resolution (same as the core backport):

- **`browser_tests/tests/customNodeLocales.spec.ts`** — `modify/delete`
conflict. This file does not exist on `cloud/1.44` (it was introduced on
`main` after the 1.44 branch cut, in #12132). Dropped the modification
from the backport; the underlying test file is not on the release
branch, so its updates are irrelevant here.

All other files merged cleanly (`src/views/GraphView.vue` had an
auto-merge that resolved without intervention).

## Verification

- `pnpm typecheck` — clean
- `pnpm test:unit src/i18n.test.ts` — **17/17 passing** (covers the new
`resolveSupportedLocale` block and the updated unsupported-locale clamp
behaviour)
- `eslint` on all changed files — clean
- **Manual verification via Playwright on the backport branch**,
simulating a fresh install with `navigator.language='de-DE'`:
- Sidebar `aria-label`s render real strings ("Assets (a)", "Node Library
(n)", "Workflows (w)", "Settings", …) — **no** literal i18n keys like
`sideToolbar.labels.assets`.
- Confirmed `hasLiteralKeys: false` on the rendered DOM, matching the
fixed behaviour from the original PR.
  - Screenshot attached.

## Files

```
 apps/desktop-ui/src/i18n.ts                     |   3 +-
 browser_tests/tests/i18nLocaleFallback.spec.ts  |  47 ++++++++
 browser_tests/tests/templates.spec.ts           |  59 +++++-----
 src/i18n.test.ts                                | 109 +++++++++++++++---
 src/i18n.ts                                     | 145 ++++++++----------------
 src/locales/CONTRIBUTING.md                     |  44 +------
 src/locales/localeConfig.ts                     |  82 ++++++++++++++
 src/platform/settings/constants/coreSettings.ts |  21 +---
 src/views/GraphView.vue                         |  20 ++--
```

Backport-of: #11712
Companion: #12178 (core/1.44)
Fixes #10563 on the 1.44 cloud release line


## Screenshots

![Cloud 1.44 backport — sidebar with navigator.language=de-DE shows real
labels (Assets, Node Library, Workflows...) instead of literal i18n
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/08da2130835cfe011512360b413c21eae085ac145ae94852e9dc841da09d0411/pr-images/1778567031488-dce52eb2-e07c-48ec-9c5e-409ffc45c49c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12179-backport-cloud-1-44-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-11-35e6d73d365081c8882cfd086ee8b90b)
by [Unito](https://www.unito.io)
2026-05-12 18:35:05 +09:00
Comfy Org PR Bot
4a30b51bee [backport cloud/1.44] fix: handle annotated output media paths in missing media scan (#12122)
Backport of #12069 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12122-backport-cloud-1-44-fix-handle-annotated-output-media-paths-in-missing-media-scan-35d6d73d36508174bfc1f2590edd1a03)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-11 01:35:14 +00:00
Comfy Org PR Bot
d891fafc3d [backport cloud/1.44] fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12084)
Backport of #12072 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12084-backport-cloud-1-44-fix-make-credits-help-icon-a-tooltip-button-in-cloud-user-popover-35a6d73d3650816eb973f20c0954696c)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-08 23:20:35 +09:00
Comfy Org PR Bot
b585afcd9c [backport cloud/1.44] fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12081)
Backport of #12051 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12081-backport-cloud-1-44-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragge-35a6d73d365081e5becbcff78471d87f)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-08 20:54:47 +09:00
Comfy Org PR Bot
5cb36fea91 [backport cloud/1.44] fix: remove asset hash verification (#12079)
Backport of #12061 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12079-backport-cloud-1-44-fix-remove-asset-hash-verification-35a6d73d36508157bec2d1259f32d142)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-08 09:11:57 +00:00
Comfy Org PR Bot
6d1221bc2f [backport cloud/1.44] refactor: align asset pagination schema (#12065)
Backport of #11899 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12065-backport-cloud-1-44-refactor-align-asset-pagination-schema-3596d73d365081a5b596c288ef8a818a)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-07 17:36:36 +00:00
201 changed files with 6554 additions and 8507 deletions

View File

@@ -20,8 +20,6 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -39,33 +37,31 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -86,7 +82,7 @@ jobs:
done
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -95,7 +91,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -104,7 +100,6 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
@@ -119,7 +114,6 @@ jobs:
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -128,9 +122,7 @@ jobs:
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
locale: getDefaultLocale(),
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -1,33 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 100 }
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-1 lg:flex"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-6 shrink-0"
class="bg-primary-comfy-yellow block size-7"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,7 +5,6 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -23,10 +22,6 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -42,10 +37,7 @@ function handleLogoLoad() {
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
},
{
label: t('useCase.advertising', locale),

View File

@@ -190,9 +190,6 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -503,7 +500,6 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({

View File

@@ -20,7 +20,6 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -82,7 +82,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.getByRole('dialog').getByRole('textbox')
return this.page.locator('.p-dialog-content input')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -116,9 +116,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -1,12 +0,0 @@
import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
constructor(public readonly root: Locator) {
this.selection = root.locator('button span span')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,15 +9,13 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -62,16 +60,13 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
/** The main content area where outputs are displayed*/
public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.mobile = new MobileAppHelper(comfyPage)
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -130,7 +125,6 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

@@ -127,7 +127,9 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -20,43 +85,20 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
}
}

View File

@@ -1,5 +1,4 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -14,7 +13,6 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -24,14 +22,13 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const evaluateParams: {
dropPosition: Position
@@ -42,22 +39,12 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName || filePath) {
const resolvedPath = filePath ?? assetPath(fileName!)
const displayName = fileName ?? basename(resolvedPath)
let buffer: Buffer
try {
buffer = readFileSync(resolvedPath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
{ cause: error }
)
}
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -161,13 +148,6 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropFilePath(
filePath: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ filePath, ...options })
}
async dragAndDropURL(
url: string,
options: {

View File

@@ -1,33 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class MobileAppHelper {
private readonly page: Page
readonly contentPanel: Locator
readonly navigation: Locator
readonly navigationTabs: Locator
readonly view: Locator
readonly workflows: Locator
constructor(comfyPage: ComfyPage) {
this.page = comfyPage.page
this.view = this.page.getByTestId(TestIds.linear.mobile)
this.contentPanel = this.page.getByRole('tabpanel')
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
this.navigationTabs = this.navigation.getByRole('tab')
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

@@ -18,7 +18,9 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
}
private get page() {

View File

@@ -362,9 +362,6 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
if (this.comfyPage.isVueNodes) {
await this.comfyPage.vueNodes.waitForNodes()
}
}
async countGraphPseudoPreviewEntries(): Promise<number> {

View File

@@ -8,6 +8,7 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -75,7 +76,15 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -144,14 +153,6 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',

View File

@@ -0,0 +1,250 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -7,9 +7,6 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.mp3')) return 'audio/mpeg'
if (name.endsWith('.flac')) return 'audio/flac'
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'

View File

@@ -1,7 +1,3 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -13,7 +13,6 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -24,7 +23,6 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
}
async getTitle(): Promise<string> {
@@ -41,16 +39,6 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -1,154 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
//an app without an image input will load the workflow
await test.step('App without an image input loads workflow', async () => {
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeHidden()
})
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
await expect(centerPanel).toBeVisible()
await expect(imageInput.root).toBeVisible()
})
await test.step('Dragging an image redirects to image input', async () => {
const initialImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropExternalResource({
fileName: 'workflow.webp',
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
await expect(imageInput.selection).not.toHaveText(initialImage)
await expect(
centerPanel,
'A file with workflow should not open a new workflow'
).toBeVisible()
})
await test.step('Dragging a url redirects to image input', async () => {
const secondImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({
filename: 'og-image.png',
type: 'input'
})
await expect(imageInput.selection).not.toHaveText(secondImage)
})
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['3', 'seed'],
['3', 'sampler_name'],
['6', 'text']
])
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
exact: true
})
const { input, incrementButton, decrementButton } =
comfyPage.vueNodes.getInputNumberControls(seed)
const initialValue = Number(await input.inputValue())
await seed.dragTo(incrementButton, { steps: 5 })
const intermediateValue = Number(await input.inputValue())
expect(intermediateValue).toBeGreaterThan(initialValue)
await seed.dragTo(decrementButton, { steps: 5 })
const endValue = Number(await input.inputValue())
expect(endValue).toBeLessThan(intermediateValue)
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
exact: true
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
const initialValue = Number(await steps.inputValue())
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue(String(initialValue + 5))
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue(String(initialValue + 2))
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -1,121 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.inputItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
//Manually set error state on checkpoint loader
//Shouldn't be needed on ci, but has spotty reliability
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget(
'Load Checkpoint',
'ckpt_name'
)
await expect(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is initially editable'
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await ksampler.header.dblclick({ force: true })
await expect(
ksampler.titleEditor.input,
'Double clicking node titles will not initiate a rename'
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
})
})

View File

@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

View File

@@ -0,0 +1,47 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

View File

@@ -1,62 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
type MetadataFixture = {
fileName: string
parser: string
}
// Each fixture embeds the same single-KSampler workflow (see
// scripts/generate-embedded-metadata-test-files.py), exercising a different
// parser in src/scripts/metadata/. Dropping the file should import that
// workflow.
const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.png', parser: 'png' },
{ fileName: 'with_metadata.avif', parser: 'avif' },
{ fileName: 'with_metadata.webp', parser: 'webp' },
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
{ fileName: 'with_metadata.flac', parser: 'flac' },
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_metadata.opus', parser: 'ogg' },
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
})
for (const { fileName, parser } of FIXTURES) {
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the fixture'
).toHaveLength(1)
})
})
}
}
)

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -692,27 +692,19 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls stack label above widget in compact mode', async ({
test('Controls collapse to single column in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
const brushButton = painterWidget.getByText('Brush', { exact: true })
await expect(
toolLabel,
'tool label should be visible in wide layout'
'tool label should be visible in two-column layout'
).toBeVisible()
const wideLabelBox = await toolLabel.boundingBox()
const wideBrushBox = await brushButton.boundingBox()
expect(
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
'label should sit to the left of the brush button in wide layout'
).toBe(true)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
@@ -724,22 +716,8 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'tool label should remain visible in compact layout'
).toBeVisible()
await expect
.poll(
async () => {
const labelBox = await toolLabel.boundingBox()
const brushBox = await brushButton.boundingBox()
if (!labelBox || !brushBox) return false
return labelBox.y + labelBox.height <= brushBox.y
},
{
message: 'label should stack above the brush button in compact layout'
}
)
.toBe(true)
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -18,70 +19,19 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
await expect(nodeInfoButton).toBeHidden()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -90,11 +40,14 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -0,0 +1,148 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.page.goto(
new URL(
`/?share=${sharedWorkflowImportScenario.shareId}`,
comfyPage.url
).toString()
)
await comfyPage.waitForAppReady()
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -558,52 +558,5 @@ test.describe(
.toBe(0)
})
})
test.fail(
'Promoted text widget is removed when source node is deleted inside the subgraph',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.first()
await expect(subgraphNode).toBeVisible()
const subgraphNodeId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('text')
await expect(
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeVisible()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await interiorClip.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNodeAfter =
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(subgraphNodeAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeHidden()
}
)
}
)

View File

@@ -106,6 +106,54 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
await comfyPage.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
await comfyPage.page.goto(
new URL('/?share=test-share-id', comfyPage.url).toString()
)
await comfyPage.waitForAppReady()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -131,48 +179,51 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const localeRequest = await localeRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -189,79 +188,4 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -75,6 +75,24 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

View File

@@ -4,6 +4,7 @@ import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
@@ -13,7 +14,9 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import type { NodeError } from '@/schemas/apiSchema'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -22,6 +25,61 @@ const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
const LOAD_IMAGE_INPUT_NAME = 'image'
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
function buildLoadImageRequiredInputError(): NodeError {
return {
class_type: 'LoadImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
details: '',
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
}
]
}
}
async function surfaceLoadImageMissingInputError(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[loadImageId]: buildLoadImageRequiredInputError()
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
}
async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const loadImageId = String(loadImageNode.id)
return {
loadImageId,
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
}
}
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -191,6 +249,74 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user drops an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('drop an image onto the Load Image node', async () => {
const dropPosition =
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
if (!dropPosition) {
throw new Error('Load Image node center must be available for drop')
}
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
dropPosition,
waitForUpload: true
})
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user pastes an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('paste an image while Load Image is selected', async () => {
await comfyPage.canvas.focus()
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
)
.toBe('LoadImage')
const uploadResponse = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
// File clipboard contents cannot be seeded reliably in Playwright;
// use the direct document paste mode to exercise usePaste.
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
mode: 'direct'
})
await uploadResponse
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {

View File

@@ -249,7 +249,6 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |

View File

@@ -1,328 +0,0 @@
# 9. Subgraph promoted widgets use linked inputs
Date: 2026-05-05
Appendices:
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
## Status
Proposed
## Context
Subgraph widget promotion historically had two overlapping representations:
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
2. linked subgraph inputs, where an interior widget-bearing input is exposed
through the subgraph boundary.
This created ambiguous ownership. Runtime value reads could collapse to an
interior source widget, while host `widgets_values` could also carry an
exterior value. Multiple host instances of the same subgraph could therefore
stomp one another, and serialization could mutate interior widgets as a
persistence carrier for exterior values.
The ECS widget migration makes that ambiguity more expensive: widgets are
becoming entities with component state keyed by stable entity identity, and
subgraphs are modeled as graph boundary structure rather than a separate
promotion-specific entity kind.
## Decision
Promoted widgets are represented only as standard linked `SubgraphInput`
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
input on a host `SubgraphNode`. The interior source widget supplies schema,
type, options, tooltip, and default metadata, but it is not the owner of the
host value.
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
promoted widget. It is a separate preview-exposure system because it has no
host-owned widget value, does not feed prompt serialization, and often points at
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
those entries. The standard serialized representation is the existing subgraph
interface/input form plus host-node `widgets_values`.
Display-only preview exposures use their own host-node-scoped serialized entry,
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
language, not widget language:
```ts
type PreviewExposure = {
name: string
sourceNodeId: string
sourcePreviewName: string
}
```
Host-node scope preserves current behavior where different instances of the
same subgraph can choose different exposed previews.
The entry intentionally stores only host preview identity and source locator
identity. `name` is the host-scoped stable identity for this preview exposure,
analogous to `SubgraphInput.name`; it is not a display label. It is generated
with existing collision behavior, such as `nextUniqueName(...)`, when an
exposure is created. Media type, display labels, titles, image/video/audio URLs,
and other runtime preview details are derived from the current graph and output
state. Array order is the canonical display order. Preview exposures do not get
a separate persisted `label` in this slice; if a future rename UX needs one, it
should follow the same rule as subgraph inputs: `name` is identity and `label`
is display-only.
Preview exposures are persisted user choices after creation. Packing nodes into
a subgraph may auto-add recommended preview exposures for supported output
nodes, and users may explicitly add or remove additional preview exposures
afterward. Normal load/save does not re-derive previews from node type alone,
because that would make old workflows change when support for new preview node
types is added. Unresolved preview exposures remain persisted and inert;
automatic cleanup does not prune them. They are removed only by explicit user
action or by destruction/unpacking of the owning host.
Preview exposures compose through nested subgraph hosts by chaining immediate
boundaries. If an outer subgraph wants to show a preview exposed by an inner
subgraph host, the outer `previewExposures` entry points at the immediate inner
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
identity, not the deepest interior preview name. Runtime preview resolution may
then follow the inner host's own preview exposures to find media. Canonical JSON
does not persist flattened deep paths, because deep paths would couple host UI
state to private nested graph internals.
## Identity and value ownership
- UI/value identity is host-scoped: host node locator plus
`SubgraphInput.name`.
- Host-scoped identity means the host `SubgraphNode` instance within its
containing `graphScope`; the interior source node is not the state or
persistence owner.
- `SubgraphInput.name` is the stable internal identity.
- `SubgraphInput.label` / `localized_name` are display-only.
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
persisted widget value key.
- Source node/widget identity remains metadata for diagnostics, missing-model
lookup, schema projection, and migration only.
- The host/exterior value wins over the interior/source value during repair,
persistence, and prompt serialization.
This follows the existing widget/slot convention: `name` is identity, `label`
is display.
Promoted-widget value state is a host-scoped sparse overlay over source-widget
metadata and defaults. The source widget remains the schema/default provider;
host value state is materialized only when the exterior value differs from the
effective source default or when restored from persisted host state. Canonical
save/load must not eagerly mirror source defaults or use interior widgets as
persistence carriers.
## Forward migration
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
not quarantined.
3. Build a multi-pass association map before mutation:
- normalized legacy proxy entry;
- projected legacy promoted-widget order;
- host `widgets_values` value, preserving sparse holes;
- repair strategy or failure reason;
- whether the entry is a value widget or display-only preview exposure.
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
is configured.
5. On flush, re-resolve against current graph state, because clone/paste/load
flows may have remapped or created nodes and links.
6. If already represented by a linked `SubgraphInput`, consider the legacy
entry resolved and consume it.
7. Otherwise repair through existing subgraph input/link systems.
8. If the entry is display-only preview surfacing, migrate it into the separate
preview-exposure representation instead of creating a linked `SubgraphInput`.
9. If value-widget repair fails, write inert quarantine metadata and warn.
The repair is idempotent. Pending plans store tuple/value data and re-check the
current graph before applying mutations.
Legacy entries are classified as preview exposures when either:
- the legacy source name starts with `$$`; or
- the source node resolves to a matching pseudo-preview widget, such as a
`serialize: false` preview/video/audio UI widget.
Everything else is treated as a value-widget promotion candidate. An unresolved
preview-shaped entry remains inert at runtime and is still persisted, because
preview-capable pseudo-widgets and output media can be removed and re-added
dynamically. It is not quarantined because it has no user value to preserve. A
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
failure and follows the quarantine path unless it can resolve to a
pseudo-preview widget.
## Proxy widget error quarantine
Valid legacy entries that cannot be repaired are persisted in
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
not hydrate runtime promoted widgets, do not participate in execution, and are
not used for app-mode/favorites identity.
Quarantine entries preserve enough information to avoid data loss and support
future tooling:
```ts
type ProxyWidgetErrorQuarantineEntry = {
originalEntry: ProxyWidgetTuple
reason:
| 'missingSourceNode'
| 'missingSourceWidget'
| 'missingSubgraphInput'
| 'ambiguousSubgraphInput'
| 'unlinkedSourceWidget'
| 'primitiveBypassFailed'
hostValue?: TWidgetValue
attemptedAtVersion: 1
}
```
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
Workflow-level promotion/value intent is preserved by
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
## Primitive-node repair
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
serve nearly the same purpose as subgraph inputs: they provide a widget value to
one or more target widget inputs. The migration repairs this expected legacy
shape in the first migration rather than quarantining it by default.
Primitive repair:
- coalesces exact duplicate legacy entries during planning;
- uses the primitive node's user title as the base input name when the node was
renamed, otherwise the primitive output widget name;
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
- uses the existing primitive merge/config compatibility logic;
- creates one `SubgraphInput` for the primitive fanout;
- reconnects every former primitive output target to that input in target
order, using standard connect/disconnect APIs;
- applies the host value when one exists, otherwise seeds from the source
primitive value;
- leaves the primitive node and its widget value in place, but disconnected and
inert.
Primitive repair is all-or-quarantine. If any target cannot be validated or
reconnected, the migration does not leave a partial rewrite; it quarantines the
entry with `hostValue` and logs the reason.
## Serialization
After repair/quarantine:
- `properties.proxyWidgets` is omitted for repaired entries;
- display-only preview entries are omitted from `properties.proxyWidgets` and
emitted through `properties.previewExposures`;
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
- preview exposures do not carry quarantine values because they do not own user
values; unresolved preview exposures remain inert in `previewExposures`;
- host `widgets_values` contains host-owned values only for canonical host
widgets, not source-owned defaults or interior persistence copies;
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
- array-form `widgets_values` remains for now.
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
preview rendering, but they do not create prompt inputs, do not create
`widgets_values`, do not alter node execution order, do not become executable
graph edges, and do not participate in prompt serialization. Runtime mapping
from backend `display_node`/output messages to a host preview exposure is a UI
projection only.
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
values into connected interior widgets is removed. A temporary TODO should mark
that removal point until the migration is proven stable. Host values are
serialized through standard subgraph-input widgets instead.
Longer term, `widgets_values` should move from array order to an object/map
keyed by stable widget name, but that migration is out of scope for this
decision.
## App mode, builder, and favorites
The runtime migration and UI identity migration ship in the same slice. The UI
must not persist promoted selections by source node/widget identity after this
change.
Canonical UI identity is:
```ts
type PromotedWidgetUiIdentity = {
hostNodeLocator: string
subgraphInputName: string
}
```
Legacy source-identity selections are migrated when they resolve through the
standard input created or confirmed by the migration. Unresolved selections are
dropped with a warning.
Preview exposure output selections are also host-scoped and must not persist
interior source node identity. Canonical preview/output identity is:
```ts
type PreviewExposureUiIdentity = {
hostNodeLocator: string
previewName: string
}
```
The UI references the explicit preview exposure itself. This keeps subgraphs
opaque: consumers select the host boundary contract, not the interior node that
currently supplies media. Legacy output selections that refer to interior
preview source nodes may migrate if they resolve to a preview-exposure chain;
otherwise they are dropped with `console.warn`. There is no separate preview UI
quarantine.
## PromotionStore
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
## Considered options
### Keep `proxyWidgets` as canonical serialized topology
Rejected. This preserves two representations for the same concept and keeps
source-widget identity in the value-ownership path.
### Preserve bare promoted widgets as degraded runtime state
Rejected. This would avoid some migration complexity, but it perpetuates the
ambiguity that caused host/source value bugs and makes ECS identity less clear.
### Quarantine primitive-node promotions by default
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
quarantining them would break users unnecessarily. They are repaired by bypassing
the primitive node when the repair can be validated all-or-nothing.
### Migrate `widgets_values` to object/map form now
Rejected for this slice. Name-keyed object form is the desired long-term
direction, but combining it with the promotion migration increases blast radius
for existing workflow consumers that still assume array order.
## Consequences
- Promoted widget values become host-instance-owned and ECS-compatible.
- Source widgets remain metadata/default providers, not persistence carriers.
- Legacy workflows are repaired toward one standard representation.
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
runtime promotion.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` has a clear removal path.

View File

@@ -1,210 +0,0 @@
# Appendix: Before and after flows
This appendix visualizes the ownership and migration flows described in
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
## Before: proxy widgets and linked inputs overlap
Historically, promoted widgets could be represented both as serialized
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
reads could collapse back to the interior source widget, while host
`widgets_values` could also carry an exterior value for the same promoted UI.
```mermaid
flowchart TD
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
workflow --> hostValues[host widgets_values]
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
promotionStore --> sourceWidget[Interior source widget]
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
sourceWidget --> hostWidget
hostValues --> hostWidget
hostWidget --> prompt[Prompt serialization]
hostWidget -. may copy value back .-> sourceWidget
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
class proxyWidgets,promotionStore legacy
class sourceWidget,hostValues ambiguous
class linkedInput,hostWidget canonical
```
Key problems in the old flow:
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
the same promotion.
- Interior source widgets supplied both schema metadata and, in some flows,
persisted host values.
- Multiple host instances of the same subgraph could stomp one another through
the shared interior widget value.
- Display-only previews were mixed into widget-promotion language even though
they do not own values or feed prompt serialization.
## After: linked inputs are the promoted-widget boundary
Promoted value widgets are now represented only as standard linked
`SubgraphInput` widgets. The source widget remains the schema/default provider,
but the host `SubgraphNode` owns the promoted value.
```mermaid
flowchart TD
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
workflow --> hostValues[host widgets_values]
subgraphInterface --> subgraphInput[SubgraphInput.name]
subgraphInput --> hostWidget[Host-scoped widget entity]
hostValues --> hostWidget
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
schema --> hostWidget
hostWidget --> prompt[Prompt serialization]
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
sourceWidget -. no host value ownership .-> schema
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
class sourceWidget,schema,diagnostics metadata
class workflow,hostValues persisted
```
Canonical ownership after the migration:
- UI/value identity is host-scoped: host node locator plus
`SubgraphInput.name`.
- `SubgraphInput.name` is stable identity; labels and localized names are
display-only.
- Host values win during repair, persistence, and prompt serialization.
- Source widgets provide metadata and defaults only.
- Canonical saves omit repaired `properties.proxyWidgets` entries.
## Legacy load migration
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
repair builds a plan before mutating graph state, then re-resolves against the
current graph when node IDs and links are stable.
```mermaid
flowchart TD
start[Load workflow] --> parse{Parse properties.proxyWidgets}
parse -->|invalid raw data| invalid[console.error and ignore]
parse -->|valid tuples| plan[Build repair plan]
plan --> classify{Classify entry}
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
valueRepair -->|yes| consume[Consume legacy proxy entry]
valueRepair -->|no| repair[Repair through subgraph input/link systems]
repair --> repairResult{Repair succeeded?}
repairResult -->|yes| consume
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
classify -->|primitive fanout| primitive[Validate all primitive targets]
primitive --> primitiveResult{All targets reconnectable?}
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
primitiveRepair --> consume
primitiveResult -->|no| quarantine
classify -->|display-only preview| preview[Create / keep previewExposures entry]
preview --> consume
consume --> save[Canonical save]
quarantine --> save
save --> omit[Omit repaired entries from proxyWidgets]
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
save --> keepPreview[Persist previews in previewExposures]
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
classDef error fill:#f8d7da,stroke:#842029,color:#330000
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
class quarantine,keepQuarantine warn
class invalid error
```
## Preview exposures are separate from value widgets
Display-only previews, such as `$$canvas-image-preview`, are not promoted
widgets. They have host-scoped serialized identity, but they do not create
prompt inputs, do not create `widgets_values`, and do not own user values.
```mermaid
flowchart TD
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
previewExposures --> exposure[PreviewExposure.name]
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
sourceLocator --> runtimePreview[Runtime preview/output state]
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
exposure --> uiIdentity[hostNodeLocator + previewName]
runtimePreview -. UI projection only .-> hostCanvas
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
previewExposures -. no value widget .-> noValue[No widgets_values entry]
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
class noPrompt,noValue,noEdge noValue
class hostNode persisted
```
For nested subgraphs, preview exposures chain across immediate host boundaries
instead of persisting flattened deep paths.
```mermaid
flowchart LR
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
outerExposure --> innerHost[Immediate inner SubgraphNode]
innerHost --> innerExposure[Inner previewExposures entry]
innerExposure --> deepestPreview[Interior preview source]
deepestPreview --> media[Resolved media]
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
class outerHost,innerHost boundary
class outerExposure,innerExposure,deepestPreview,media preview
class opaque note
```
## Serialization summary
```mermaid
flowchart TD
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
canonical --> values[widgets_values for host-owned values]
canonical --> previews[properties.previewExposures]
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
inputs --> valueWidgets[Promoted value widgets]
values --> valueWidgets
previews --> previewUi[Display-only preview UI]
quarantine --> futureTooling[Future recovery tooling]
valueWidgets --> prompt[Prompt serialization]
previewUi -. not serialized into prompt .-> prompt
quarantine -. inert .-> prompt
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
class inputs,values,valueWidgets,prompt,canonical canonical
class previews,previewUi,quarantine,futureTooling inert
class noProxy removed
```

View File

@@ -1,147 +0,0 @@
# Appendix: Removing `disambiguatingSourceNodeId`
This appendix explains where the existing promotion system needs
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
removes the pattern from promoted-widget identity.
## Why the disambiguator exists
The legacy promotion model identifies a promoted widget by source location:
```ts
type PromotedWidgetSource = {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
```
`sourceNodeId` is the immediate interior node visible from the host subgraph.
That is not always the original widget owner. When promotions pass through
nested subgraphs, two promoted widgets can have the same immediate
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
can choose the right promoted view.
```mermaid
flowchart TD
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
middleNode --> middleWidgetA[Promoted widget view: text]
middleNode --> middleWidgetB[Promoted widget view: text]
middleWidgetA --> leafA[Leaf source node 17 / widget text]
middleWidgetB --> leafB[Leaf source node 42 / widget text]
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
middleWidgetA -. requires .-> oldKeyA
middleWidgetB -. requires .-> oldKeyB
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
class outerHost host
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
class leafA,leafB leaf
```
The disambiguator is therefore not a domain concept. It is compensating for an
identity model that asks host UI state to identify private nested internals.
## Existing places that need it
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
## New promoted-widget identity
ADR 0009 moves promoted value identity to the host boundary:
```ts
type PromotedWidgetUiIdentity = {
hostNodeLocator: string
subgraphInputName: string
}
```
The canonical widget is owned by a `SubgraphInput` on the host
`SubgraphNode`. The host widget no longer needs to identify the deepest source
node to preserve value identity. The source widget is consulted for schema,
defaults, diagnostics, and migration, but it is not the value owner.
```mermaid
flowchart TD
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
host --> inputB[SubgraphInput.name: negative_prompt]
inputA --> hostWidgetA[Host-owned widget entity]
inputB --> hostWidgetB[Host-owned widget entity]
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
sourceA -. not part of host value key .-> identityA
sourceB -. not part of host value key .-> identityB
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
class sourceA,sourceB metadata
```
This is the same rule the subgraph interface already uses: `name` is stable
identity, and `label` / `localized_name` are display-only.
## How the new form removes each need
| Previous disambiguation site | New canonical replacement |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
## Boundary-by-boundary nested flow
The new form avoids flattened deep source paths. Each host boundary exposes its
own named input, and the next outer host links to that immediate boundary
contract.
```mermaid
flowchart LR
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
innerInput --> innerHostWidget[Inner host-owned widget]
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
outerInput --> outerHostWidget[Outer host-owned widget]
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
leaf -. schema/default source .-> innerHostWidget
leaf -. not persisted as outer value key .-> outerIdentity
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
classDef source fill:#cff4fc,stroke:#055160,color:#032830
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
class leaf source
```
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
widgets no longer require a persisted leaf-node disambiguator at the outer host.
If the user exposes both, the collision is resolved when the host inputs are
created by assigning distinct input names with the existing unique-name
behavior.

View File

@@ -1,37 +0,0 @@
# Appendix: System comparison
This appendix compares the legacy promoted-widget systems with the canonical
linked-input model chosen by
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
## Practical migration summary
| Legacy shape | New result |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |

View File

@@ -231,11 +231,6 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
source node/widget identity is preserved only as migration and diagnostic
metadata.
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
@@ -668,10 +663,6 @@ The 6 proto-ECS stores use 6 different keying strategies:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
source node/widget identity.
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic

View File

@@ -17,10 +17,6 @@ Six stores extract entity state out of class instances into centralized, queryab
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
source node/widget identity is migration and diagnostic metadata only.
## 2. WidgetValueStore
**File:** `src/stores/widgetValueStore.ts`
@@ -258,9 +254,6 @@ Each store invents its own identity scheme:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
identity (`host node locator + SubgraphInput.name`) instead of interior source
identity.
## 6. Extraction Map

View File

@@ -404,21 +404,26 @@ Whichever candidate is chosen:
instance-specific state beyond inputs — must remain reachable. This is a
constraint, not a current requirement.
### Decision
### Recommendation and decision criteria
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
chooses Candidate A for promoted value widgets. It eliminates an entire
subsystem by recognizing a structural truth: promotion is adding a typed input
to a function signature. The type system already handles widget creation for
typed inputs. Building a parallel mechanism for "promoted widgets" is building
a second, narrower version of something the system already does.
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
truth: promotion is adding a typed input to a function signature. The type
system already handles widget creation for typed inputs. Building a parallel
mechanism for "promoted widgets" is building a second, narrower version of
something the system already does.
The cost of A is a migration path for existing `proxyWidgets` serialization. On
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
into interface inputs and boundary links. Once loaded and re-saved, the workflow
uses the new format. ADR 0009 separates display-only preview exposures from
promoted value widgets; those previews use their own host-scoped serialized
representation instead of linked `SubgraphInput` widgets.
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
inputs and boundary links. This is a one-time ratchet conversion — once
loaded and re-saved, the workflow uses the new format.
**Choose B if** the team determines that promoted widgets must remain
visually or behaviorally distinct from normal input widgets in ways the type →
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
the current release cycle's capacity.
**Decision needed before** Phase 3 of the ECS migration, when systems are
introduced and the widget/connectivity architecture solidifies.
---
@@ -466,14 +471,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
| Direction | Format | Notes |
| --------------- | ------------------------------- | ------------------------------------------ |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
| Direction | Format | Notes |
| --------------- | ------------------------------- | ---------------------------------------- |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
The migration pattern: load any supported format and normalize to the internal
model. The system accepts old formats indefinitely but produces the current
format on save.
The "ratchet conversion" pattern: load any supported format, normalize to the
internal model. The system accepts old formats indefinitely but produces the
current format on save.
### Widget identity at the boundary
@@ -506,12 +511,13 @@ SubgraphIO {
}
```
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
widgets: they become interface inputs, serialized as additional `SubgraphIO`
entries. On load, legacy value-widget `proxyWidgets` data is converted to
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
are no longer written. Display-only preview exposures use separate
host-scoped `previewExposures` serialization.
If Candidate A (connections-only promotion) is chosen: promoted widgets become
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
migration). On save, `proxyWidgets` is no longer written.
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
serialized in its current format.
### Backward-compatible loading contract
@@ -549,7 +555,7 @@ This document proposes or surfaces the following changes to
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |

View File

@@ -26,6 +26,10 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.2",
"version": "1.44.18",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -11,7 +11,7 @@
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",

View File

@@ -524,9 +524,18 @@ export type ImportPublishedAssetsRequest = {
*/
published_asset_ids: Array<string>
/**
* The share ID of the published workflow these assets belong to. Required for authorization.
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
*/
share_id: string
share_id?: string | null
}
/**

View File

@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
share_id: z.string().min(1).max(64)
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
})
/**

View File

@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
})
})
describe('joinFilePath', () => {
it('joins subfolder and filename with normalized slash separators', () => {
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
'nested/folder/child/file.png'
)
})
it('trims boundary separators without changing the filename body', () => {
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
'nested/folder/file.png'
)
})
it('returns the normalized filename when no subfolder is provided', () => {
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
})
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
})
})
describe('getFilePathSeparatorVariants', () => {
it('returns slash and backslash variants for nested paths', () => {
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
'nested/folder/file.png',
'nested\\folder\\file.png'
])
})
it('returns a single value when no separator is present', () => {
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')

View File

@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
}
}
export function joinFilePath(
subfolder: string | null | undefined,
filename: string | null | undefined
): string {
const normalizedSubfolder = normalizeFilePathSeparators(
subfolder ?? ''
).replace(/^\/+|\/+$/g, '')
const normalizedFilename = normalizeFilePathSeparators(
filename ?? ''
).replace(/^\/+/g, '')
if (!normalizedSubfolder) return normalizedFilename
if (!normalizedFilename) return normalizedSubfolder
return `${normalizedSubfolder}/${normalizedFilename}`
}
export function getFilePathSeparatorVariants(filepath: string): string[] {
const slashPath = normalizeFilePathSeparators(filepath)
const backslashPath = slashPath.replace(/\//g, '\\')
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
}
function normalizeFilePathSeparators(filepath: string): string {
return filepath.replace(/[\\/]+/g, '/')
}
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
const normalizedPath = normalizeFilePathSeparators(filepath)
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash

View File

@@ -3,7 +3,6 @@
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,

View File

@@ -19,7 +19,6 @@ import subprocess
import av
from PIL import Image
from PIL.PngImagePlugin import PngInfo
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
@@ -116,15 +115,6 @@ def generate_av_fixture(
report(name)
def generate_png():
img = make_1x1_image()
info = PngInfo()
info.add_text('workflow', WORKFLOW_JSON)
info.add_text('prompt', PROMPT_JSON)
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
report('with_metadata.png')
def generate_webp():
img = make_1x1_image()
exif = build_exif_bytes()
@@ -177,7 +167,6 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
generate_png()
generate_webp()
generate_avif()
generate_flac()

20
src/base/wheelGestures.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -11,7 +11,6 @@
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>

View File

@@ -1,11 +1,10 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import { useDialogStore } from '@/stores/dialogStore'
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
type Props = ComponentProps<typeof ConfirmationDialogContent>
@@ -13,23 +12,7 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
overwrite: 'Overwrite',
save: 'Save',
no: 'No',
ok: 'OK',
close: 'Close'
},
desktopMenu: {
reinstall: 'Reinstall'
}
}
},
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
@@ -40,9 +23,10 @@ describe('ConfirmationDialogContent', () => {
})
function renderComponent(props: Partial<Props> = {}) {
const user = userEvent.setup()
render(ConfirmationDialogContent, {
global: { plugins: [i18n] },
return render(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
props: {
message: 'Test message',
type: 'default',
@@ -50,7 +34,6 @@ describe('ConfirmationDialogContent', () => {
...props
} as Props
})
return { user }
}
it('renders long messages without breaking layout', () => {
@@ -59,104 +42,4 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('renders the hint as a status alert when provided', () => {
renderComponent({ hint: 'This action cannot be undone.' })
const status = screen.getByRole('status')
expect(status).toHaveTextContent('This action cannot be undone.')
})
it('does not render a status alert when hint is omitted', () => {
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
describe('button surface per type', () => {
it("type='default' renders Cancel and Confirm", () => {
renderComponent({ type: 'default' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Confirm' })
).toBeInTheDocument()
})
it("type='delete' renders Cancel and Delete", () => {
renderComponent({ type: 'delete' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it("type='overwrite' renders Cancel and Overwrite", () => {
renderComponent({ type: 'overwrite' })
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Overwrite' })
).toBeInTheDocument()
})
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
renderComponent({ type: 'dirtyClose' })
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it("type='info' renders only OK (no Cancel)", () => {
renderComponent({ type: 'info' })
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Cancel' })
).not.toBeInTheDocument()
})
})
it('confirm callback receives true and closes the dialog', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ type: 'default', onConfirm })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await user.click(screen.getByRole('button', { name: 'Confirm' }))
expect(onConfirm).toHaveBeenCalledWith(true)
expect(closeSpy).toHaveBeenCalledOnce()
})
describe('dirtyClose deny label', () => {
it('uses the provided denyLabel for the deny button', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'No' })
).not.toBeInTheDocument()
})
it('falls back to "No" when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
})
})

View File

@@ -9,14 +9,16 @@
{{ item }}
</li>
</ul>
<div
<Message
v-if="hint"
role="status"
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
class="mt-2"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
>
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
<span>{{ hint }}</span>
</div>
{{ hint }}
</Message>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-4">
<div
@@ -53,7 +55,7 @@
</div>
<Button
v-if="type !== 'info' && type !== 'dirtyClose'"
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
@@ -84,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ denyLabel ?? $t('g.no') }}
{{ $t('g.no') }}
</Button>
<Button autofocus @click="onConfirm">
<Button @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -113,6 +115,7 @@
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -128,7 +131,6 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

View File

@@ -10,7 +10,7 @@
<a
v-bind="props.action"
class="flex items-center gap-2 px-3 py-1.5"
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
@click="onItemClick($event, item)"
>
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
<span class="flex-1">{{ item.label }}</span>
@@ -21,20 +21,27 @@
{{ item.shortcut }}
</span>
<i
v-if="hasSubmenu || item.isColorSubmenu"
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>
<!-- Color picker menu (custom with color circles) -->
<ColorPickerMenu
<SubmenuPopover
v-if="colorOption"
ref="colorPickerMenu"
key="color-picker-menu"
ref="colorSubmenu"
key="color-submenu"
:option="colorOption"
@submenu-click="handleColorSelect"
@submenu-click="handleSubmenuSelect"
/>
<SubmenuPopover
v-if="shapeOption"
ref="shapeSubmenu"
key="shape-submenu"
:option="shapeOption"
@submenu-click="handleSubmenuSelect"
/>
</template>
@@ -54,16 +61,18 @@ import type {
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
interface ExtendedMenuItem extends MenuItem {
isColorSubmenu?: boolean
isShapeSubmenu?: boolean
shortcut?: string
originalOption?: MenuOption
}
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
@@ -150,21 +159,20 @@ useEventListener(
{ passive: true }
)
// Find color picker option
const colorOption = computed(() =>
menuOptions.value.find((opt) => opt.isColorPicker)
)
// Check if option is the color picker
function isColorOption(option: MenuOption): boolean {
return Boolean(option.isColorPicker)
}
const shapeOption = computed(() =>
menuOptions.value.find((opt) => opt.isShapePicker)
)
// Convert MenuOption to PrimeVue MenuItem
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
if (option.type === 'divider') return { separator: true }
const isColor = isColorOption(option)
const isColor = Boolean(option.isColorPicker)
const isShape = Boolean(option.isShapePicker)
const usesPopover = isColor || isShape
const item: ExtendedMenuItem = {
label: option.label,
@@ -172,11 +180,14 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
disabled: option.disabled,
shortcut: option.shortcut,
isColorSubmenu: isColor,
isShapeSubmenu: isShape,
originalOption: option
}
// Native submenus for non-color options
if (option.hasSubmenu && option.submenu && !isColor) {
// Submenus opened via popover (color, shape) deliberately omit `items` so
// PrimeVue does not render a nested <ul> inside the scrollable root list,
// which would be clipped when the menu overflows the viewport (FE-570).
if (option.hasSubmenu && option.submenu && !usesPopover) {
item.items = option.submenu.map((sub) => ({
label: sub.label,
icon: sub.icon,
@@ -188,7 +199,6 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
}))
}
// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action?.()
@@ -245,17 +255,30 @@ function toggle(event: Event) {
defineExpose({ toggle, hide, isOpen, show })
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(event, target)
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
if (item.isColorSubmenu) {
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
} else if (item.isShapeSubmenu) {
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
}
}
// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
function openSubmenuPopover(
event: MouseEvent,
target: InstanceType<typeof SubmenuPopover> | undefined,
other: InstanceType<typeof SubmenuPopover> | undefined
) {
if (!target) return
event.stopPropagation()
event.preventDefault()
other?.hide()
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
target.toggle(event, anchor)
}
function handleSubmenuSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}
@@ -270,11 +293,17 @@ function constrainMenuHeight() {
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
const availableHeight = window.innerHeight - rect.top - 8
if (availableHeight <= 0) return
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
// to a non-visible value too (CSS spec), which clips horizontally-opening
// submenus like Shape. Only apply the constraint when content truly
// overflows so the common case keeps overflow visible.
if (rootList.scrollHeight <= availableHeight) return
rootList.style.maxHeight = `${availableHeight}px`
rootList.style.overflowY = 'auto'
}
function onMenuShow() {

View File

@@ -1,6 +1,7 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -29,6 +30,26 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
>
}
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
const defaultSettingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': true
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingValues = {
...defaultSettingValues,
...overrides
}
settingGetMock.mockImplementation(
(key: string): unknown => settingValues[key] ?? null
)
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
@@ -79,10 +100,7 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Load3D.3DViewerEnable') return true
return null
})
get: settingGetMock
})
}))
@@ -128,7 +146,7 @@ describe('SelectionToolbox', () => {
}
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
canvasStore = useCanvasStore()
nodeDefMock = {
type: 'TestNode',
@@ -139,6 +157,7 @@ describe('SelectionToolbox', () => {
canvasStore.canvas = createMockCanvas()
vi.resetAllMocks()
mockSettingValues()
})
function renderComponent(props = {}): { container: Element } {
@@ -231,6 +250,42 @@ describe('SelectionToolbox', () => {
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the new node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should show info button when new menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeTruthy()
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]

View File

@@ -16,8 +16,8 @@
@wheel="canvasInteractions.forwardEventToCanvas"
>
<DeleteButton v-if="showDelete" />
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
<InfoButton v-if="showInfoButton" />
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
<InfoButton v-if="canOpenNodeInfo" />
<ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" />
@@ -105,9 +105,8 @@ const {
isSingleImageNode,
hasAny3DNodeSelected,
hasOutputNodesSelected,
nodeDef
canOpenNodeInfo
} = useSelectionState()
const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,19 +8,20 @@ import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
import Button from '@/components/ui/button/Button.vue'
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
openNodeInfoMock: vi.fn(),
trackUiButtonClickedMock: vi.fn()
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => ({
openNodeInfo: openNodeInfoMock
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: vi.fn()
trackUiButtonClicked: trackUiButtonClickedMock
})
}))
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
openNodeInfoMock.mockReturnValue(true)
})
const renderComponent = () => {
@@ -53,12 +53,29 @@ describe('InfoButton', () => {
})
}
it('should open the info panel on click', async () => {
const clickNodeInfoButton = async () => {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
}
it('should open the node info panel on click', async () => {
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
await clickNodeInfoButton()
expect(openPanelMock).toHaveBeenCalledWith('info')
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
})
})
it('should not track the click when the node info panel is unavailable', async () => {
openNodeInfoMock.mockReturnValue(false)
renderComponent()
await clickNodeInfoButton()
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
})
})

View File

@@ -15,18 +15,16 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const rightSidePanelStore = useRightSidePanelStore()
const { openNodeInfo } = useSelectionState()
/**
* Track node info button click and toggle node help.
*/
const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
})
rightSidePanelStore.openPanel('info')
}
</script>

View File

@@ -8,7 +8,7 @@
unstyled
:pt="{
root: {
class: 'absolute z-60'
class: 'p-popover absolute z-60'
},
content: {
class: [
@@ -90,8 +90,12 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}
const hide = () => {
popoverRef.value?.hide()
}
defineExpose({
toggle
toggle,
hide
})
const handleSubmenuClick = (subOption: SubMenuOption) => {

View File

@@ -1,164 +0,0 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
const commandStoreExecute = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
},
get isNightly() {
return distribution.isNightly
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: { discord: '', github: '' },
buildDocsUrl: () => 'https://docs.comfy.org'
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => false
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackHelpResourceClicked: vi.fn(),
trackHelpCenterOpened: vi.fn(),
trackHelpCenterClosed: vi.fn()
})
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
releases: [],
recentReleases: [],
isLoading: false,
fetchReleases: vi.fn().mockResolvedValue(undefined)
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: commandStoreExecute })
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => null
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
})
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({ isNewManagerUI: { value: false } })
}))
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: () => ({})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
default: defineComponent({
name: 'PuzzleIconStub',
render: () => h('div')
})
}))
function renderComponent() {
const user = userEvent.setup()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const result = render(HelpCenterMenuContent, {
global: {
plugins: [i18n]
}
})
return { user, ...result }
}
describe('HelpCenterMenuContent feedback item', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
commandStoreExecute.mockReset()
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
cleanup()
})
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
'_blank',
'noopener,noreferrer'
)
expect(commandStoreExecute).not.toHaveBeenCalled()
})
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
distribution.isNightly = true
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
'_blank',
'noopener,noreferrer'
)
expect(commandStoreExecute).not.toHaveBeenCalled()
})
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).not.toHaveBeenCalled()
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})

View File

@@ -163,7 +163,6 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -307,7 +306,7 @@ const menuItems = computed<MenuItem[]>(() => {
trackResourceClick('help_feedback', isCloud || isNightly)
if (isCloud || isNightly) {
window.open(
buildFeedbackTypeformUrl('help-center'),
'https://form.typeform.com/to/q7azbWPi',
'_blank',
'noopener,noreferrer'
)

View File

@@ -2,6 +2,7 @@
<div
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
data-testid="node-preview-card"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div

View File

@@ -1,334 +0,0 @@
import { fireEvent, render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
const sizeHolder = vi.hoisted(() => ({ width: 0, height: 0 }))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useElementSize: () => ({
width: ref(sizeHolder.width),
height: ref(sizeHolder.height)
})
}
})
const painterHolder = vi.hoisted(() => ({
state: null as Record<string, unknown> | null
}))
function createDefaultPainterState() {
return {
tool: ref('brush'),
brushSize: ref(20),
brushColor: ref('#000000'),
brushOpacity: ref(1),
brushHardness: ref(1),
backgroundColor: ref('#ffffff'),
canvasWidth: ref(512),
canvasHeight: ref(512),
cursorVisible: ref(true),
displayBrushSize: ref(20),
inputImageUrl: ref<string | null>(null),
isImageInputConnected: ref(false),
handlePointerDown: vi.fn(),
handlePointerMove: vi.fn(),
handlePointerUp: vi.fn(),
handlePointerEnter: vi.fn(),
handlePointerLeave: vi.fn(),
handleInputImageLoad: vi.fn(),
handleClear: vi.fn()
}
}
vi.mock('@/composables/painter/usePainter', () => ({
PAINTER_TOOLS: { BRUSH: 'brush', ERASER: 'eraser' } as const,
usePainter: () => {
if (!painterHolder.state) painterHolder.state = createDefaultPainterState()
return painterHolder.state
}
}))
import WidgetPainter from './WidgetPainter.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
painter: {
tool: 'Tool',
brush: 'Brush',
eraser: 'Eraser',
size: 'Size',
color: 'Color',
hardness: 'Hardness',
width: 'Width',
height: 'Height',
background: 'Background',
clear: 'Clear'
}
}
}
})
const ButtonStub = defineComponent({
name: 'Button',
inheritAttrs: false,
template: '<button v-bind="$attrs" type="button"><slot /></button>'
})
const SliderStub = defineComponent({
name: 'Slider',
props: {
modelValue: { type: Array, default: () => [] },
min: Number,
max: Number,
step: Number
},
emits: ['update:modelValue'],
template:
'<div data-testid="slider-stub" :data-min="min" @click="$emit(\'update:modelValue\', [Number(min) + Number(step ?? 1)])" />'
})
function primePainterState(overrides: Record<string, unknown> = {}) {
painterHolder.state = { ...createDefaultPainterState(), ...overrides }
}
function renderWidget(initialModel = '') {
const value = ref(initialModel)
const Harness = defineComponent({
components: { WidgetPainter },
setup: () => ({ value }),
template: '<WidgetPainter v-model="value" node-id="42" />'
})
return render(Harness, {
global: {
plugins: [i18n],
stubs: { Button: ButtonStub, Slider: SliderStub }
}
})
}
describe('WidgetPainter', () => {
beforeEach(() => {
sizeHolder.width = 0
sizeHolder.height = 0
painterHolder.state = null
})
describe('Label visibility', () => {
const allLabels = [
'Tool',
'Size',
'Color',
'Hardness',
'Width',
'Height',
'Background'
]
it('renders every label in wide layout (width >= 350)', () => {
sizeHolder.width = 600
primePainterState()
renderWidget()
for (const label of allLabels) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
it('still renders every label in compact layout (width < 350)', () => {
sizeHolder.width = 200
primePainterState()
renderWidget()
for (const label of allLabels) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
it('keeps labels at the responsive boundary (width = 350)', () => {
sizeHolder.width = 350
primePainterState()
renderWidget()
for (const label of allLabels) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
})
describe('Image-input branch', () => {
it('hides canvas-size and background controls when an image is connected', () => {
primePainterState({
isImageInputConnected: ref(true),
inputImageUrl: ref('/img.png')
})
renderWidget()
expect(screen.queryByText('Width')).toBeNull()
expect(screen.queryByText('Height')).toBeNull()
expect(screen.queryByText('Background')).toBeNull()
expect(screen.getByTestId('painter-dimension-text')).toBeInTheDocument()
})
it('renders the input image inside the canvas container', () => {
primePainterState({
isImageInputConnected: ref(true),
inputImageUrl: ref('/img.png')
})
renderWidget()
const container = screen.getByTestId('painter-canvas-container')
expect(within(container).getByRole('img')).toBeInTheDocument()
})
})
describe('Tool selection', () => {
it('hides brush-only controls when the eraser tool is active', () => {
primePainterState({ tool: ref('eraser') })
renderWidget()
expect(screen.queryByText('Color')).toBeNull()
expect(screen.queryByText('Hardness')).toBeNull()
})
it('updates the active tool when clicking brush/eraser buttons', async () => {
const tool = ref<'brush' | 'eraser'>('brush')
primePainterState({ tool })
renderWidget()
const user = userEvent.setup()
await user.click(screen.getByText('Eraser'))
expect(tool.value).toBe('eraser')
await user.click(screen.getByText('Brush'))
expect(tool.value).toBe('brush')
})
})
describe('Canvas events', () => {
it('forwards pointerdown/up to the composable on click', async () => {
primePainterState()
renderWidget()
const user = userEvent.setup()
await user.click(screen.getByTestId('painter-canvas'))
const s = painterHolder.state!
expect(s.handlePointerDown).toHaveBeenCalled()
expect(s.handlePointerUp).toHaveBeenCalled()
})
it('forwards pointerenter/leave to the composable on hover', async () => {
primePainterState()
renderWidget()
const user = userEvent.setup()
const canvas = screen.getByTestId('painter-canvas')
await user.hover(canvas)
await user.unhover(canvas)
const s = painterHolder.state!
expect(s.handlePointerEnter).toHaveBeenCalled()
expect(s.handlePointerLeave).toHaveBeenCalled()
})
it('invokes handleInputImageLoad when the input image fires load', async () => {
primePainterState({
isImageInputConnected: ref(true),
inputImageUrl: ref('/img.png')
})
renderWidget()
const img = within(
screen.getByTestId('painter-canvas-container')
).getByRole('img')
await fireEvent.load(img)
expect(painterHolder.state!.handleInputImageLoad).toHaveBeenCalled()
})
})
describe('Control bindings', () => {
it('invokes handleClear when the clear button is clicked', async () => {
primePainterState()
renderWidget()
const user = userEvent.setup()
await user.click(screen.getByTestId('painter-clear-button'))
expect(painterHolder.state!.handleClear).toHaveBeenCalled()
})
it('updates brushSize via the size slider', async () => {
const brushSize = ref(20)
primePainterState({ brushSize })
renderWidget()
const user = userEvent.setup()
const slider = within(screen.getByTestId('painter-size-row')).getByTestId(
'slider-stub'
)
await user.click(slider)
expect(brushSize.value).toBe(2) // min=1, step=1 -> emits 2
})
it('updates brushColor via the color picker', async () => {
const brushColor = ref('#000000')
primePainterState({ brushColor })
renderWidget()
const colorInput = within(
screen.getByTestId('painter-color-row')
).getByDisplayValue('#000000')
// <input type="color"> has no userEvent equivalent — fire input directly
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.input(colorInput, { target: { value: '#ff0000' } })
expect(brushColor.value.toLowerCase()).toBe('#ff0000')
})
it('updates brushOpacity via the percent input', async () => {
const brushOpacity = ref(1)
primePainterState({ brushOpacity })
renderWidget()
const user = userEvent.setup()
const percentInput = within(
screen.getByTestId('painter-color-row')
).getByDisplayValue('100')
await user.clear(percentInput)
await user.type(percentInput, '50')
await user.tab() // blur to trigger @change
expect(brushOpacity.value).toBeCloseTo(0.5)
})
it('clamps opacity input to the 0-100 range', async () => {
const brushOpacity = ref(1)
primePainterState({ brushOpacity })
renderWidget()
const user = userEvent.setup()
const percentInput = within(
screen.getByTestId('painter-color-row')
).getByDisplayValue('100')
await user.clear(percentInput)
await user.type(percentInput, '999')
await user.tab()
expect(brushOpacity.value).toBe(1) // clamped to 100% -> 1.0
})
it('updates background color via the bg color input', async () => {
const backgroundColor = ref('#ffffff')
primePainterState({ backgroundColor })
renderWidget()
const bgInput = within(
screen.getByTestId('painter-bg-color-row')
).getByDisplayValue('#ffffff')
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.input(bgInput, { target: { value: '#00ff00' } })
expect(backgroundColor.value.toLowerCase()).toBe('#00ff00')
})
})
})

View File

@@ -23,7 +23,6 @@
/>
<canvas
ref="canvasEl"
data-testid="painter-canvas"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@@ -59,6 +58,7 @@
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
@@ -99,6 +99,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
@@ -125,6 +126,7 @@
<template v-if="tool === PAINTER_TOOLS.BRUSH">
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.color') }}
@@ -168,6 +170,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
@@ -196,6 +199,7 @@
<template v-if="!isImageInputConnected">
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
@@ -218,6 +222,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
@@ -240,6 +245,7 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
@@ -21,19 +21,13 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}
}))
const mockRect = (el: HTMLElement, width: number) => {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: width,
bottom: 100,
width,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
}
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
elementWidth: ref(100),
isOutside: ref(false)
})
}))
describe('CompareSliderThumbnail', () => {
const renderThumbnail = (props = {}) => {
@@ -80,44 +74,4 @@ describe('CompareSliderThumbnail', () => {
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
it('updates slider position on mousemove', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('25%')
})
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: -10 } })
let divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('0%')
await user.pointer({ target: container, coords: { clientX: 250 } })
divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('100%')
})
it('ignores mousemove when container has zero width', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 0)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
})

View File

@@ -9,11 +9,7 @@
: 'max-w-full max-h-64 object-contain'
"
/>
<div
data-testid="compare-slider-container"
class="absolute inset-0"
@mousemove="updateSliderPosition"
>
<div ref="containerRef" class="absolute inset-0">
<LazyImage
:src="overlayImageSrc"
:alt="alt"
@@ -38,7 +34,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
@@ -60,20 +57,18 @@ const isVideoType =
false
const sliderPosition = ref(SLIDER_START_POSITION)
const containerRef = ref<HTMLElement | null>(null)
/**
* Update slider position from a local mousemove. Scoped to currentTarget so
* only the hovered card reads its rect — unlike useMouseInElement which
* attaches a global mousemove listener and fires for every mounted instance.
*/
function updateSliderPosition(event: MouseEvent) {
const el = event.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width === 0) return
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
// push the raw percentage slightly out of range, which would offset the
// divider past the container or invert the overlay's clipPath.
const raw = ((event.clientX - rect.left) / rect.width) * 100
sliderPosition.value = Math.max(0, Math.min(100, raw))
}
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
// Update slider position based on mouse position when hovered
watch(
[() => isHovered, elementX, elementWidth, isOutside],
([isHovered, x, width, outside]) => {
if (!isHovered) return
if (!outside) {
sliderPosition.value = (x / width) * 100
}
}
)
</script>

View File

@@ -23,7 +23,6 @@
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>

View File

@@ -1,186 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import WorkflowTabs from './WorkflowTabs.vue'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
},
get isNightly() {
return distribution.isNightly
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: { value: false } })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
isOverflowing: { value: false },
disposed: { value: false },
checkOverflow: vi.fn(),
dispose: vi.fn()
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
openWorkflow: vi.fn(),
closeWorkflow: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () =>
reactive({
openWorkflows: [],
activeWorkflow: null
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
vi.mock('@/utils/mouseDownUtil', () => ({
whileMouseDown: vi.fn()
}))
vi.mock('./WorkflowOverflowMenu.vue', () => ({
default: defineComponent({
name: 'WorkflowOverflowMenuStub',
render: () => h('div')
})
}))
vi.mock('./WorkflowTab.vue', () => ({
default: defineComponent({
name: 'WorkflowTabStub',
render: () => h('div')
})
}))
vi.mock('./CurrentUserButton.vue', () => ({
default: defineComponent({
name: 'CurrentUserButtonStub',
render: () => h('div')
})
}))
vi.mock('./LoginButton.vue', () => ({
default: defineComponent({
name: 'LoginButtonStub',
render: () => h('div')
})
}))
function renderComponent() {
const user = userEvent.setup()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const result = render(WorkflowTabs, {
global: {
plugins: [i18n],
directives: {
tooltip: {}
}
}
})
return { user, ...result }
}
describe('WorkflowTabs feedback button', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
tabBarLayout.value = 'Default'
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
})
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
'_blank',
'noopener,noreferrer'
)
})
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
distribution.isNightly = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
'_blank',
'noopener,noreferrer'
)
})
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
renderComponent()
expect(
screen.queryByRole('button', { name: 'Feedback' })
).not.toBeInTheDocument()
})
it('does not render the feedback button when the legacy tab bar is active', () => {
distribution.isCloud = true
tabBarLayout.value = 'Legacy'
renderComponent()
expect(
screen.queryByRole('button', { name: 'Feedback' })
).not.toBeInTheDocument()
})
})

View File

@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { buildFeedbackUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -152,12 +152,9 @@ const isIntegratedTabBar = computed(
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(
buildFeedbackTypeformUrl('topbar'),
'_blank',
'noopener,noreferrer'
)
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
}
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -1,195 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
modifiedWorkflows: [] as ModifiedWorkflow[]
}))
const mockWorkflowService = vi.hoisted(() => ({
saveWorkflow: vi.fn().mockResolvedValue(true)
}))
const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => mockWorkflowService)
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
})
}))
function makeWorkflow(path: string): ModifiedWorkflow {
return { path, isModified: true } satisfies ModifiedWorkflow
}
describe('useAuthActions.logout', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
})
it('logs out without prompting when no workflows are modified', async () => {
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).not.toHaveBeenCalled()
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when the dialog is dismissed (null)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('signs out without saving when the user picks "Sign out anyway" (false)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when saving a workflow is cancelled', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('does not log out if a workflow save fails', async () => {
mockWorkflowStore.modifiedWorkflows = [
makeWorkflow('a.json'),
makeWorkflow('b.json')
]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockRejectedValueOnce(
new Error('disk full')
)
const { logout } = useAuthActions()
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
const workflows = [makeWorkflow('a.json'), makeWorkflow('b.json')]
mockWorkflowStore.modifiedWorkflows = workflows
mockDialogService.confirm.mockResolvedValueOnce(true)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(2)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
1,
workflows[0]
)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
2,
workflows[1]
)
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1]
).toBeLessThan(mockAuthStore.logout.mock.invocationCallOrder[0])
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[0]
).toBeLessThan(mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1])
})
it('passes denyLabel "Sign out anyway" to the dialog', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dirtyClose',
title: 'auth.signOut.unsavedChangesTitle',
message: 'auth.signOut.unsavedChangesMessage',
denyLabel: 'auth.signOut.signOutAnyway'
})
)
})
})

View File

@@ -9,7 +9,6 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -54,30 +53,14 @@ export const useAuthActions = () => {
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
const modifiedWorkflows = workflowStore.modifiedWorkflows
if (modifiedWorkflows.length > 0) {
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose',
denyLabel: t('auth.signOut.signOutAnyway')
type: 'dirtyClose'
})
if (confirmed === null) return
if (confirmed === true) {
const workflowService = useWorkflowService()
for (const workflow of modifiedWorkflows) {
try {
const saved = await workflowService.saveWorkflow(workflow)
if (!saved) return
} catch {
throw new Error(
t('auth.signOut.saveFailed', { workflow: workflow.path })
)
}
}
}
if (!confirmed) return
}
await authStore.logout()

View File

@@ -21,6 +21,12 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
beforeEach(() => {
vi.restoreAllMocks()
})
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
@@ -205,6 +211,47 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears missing media when an upload emits onWidgetChanged', () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
node.type = 'LoadImage'
const widget = node.addWidget(
'combo',
'image',
'missing.png',
() => undefined,
{ values: [] }
)
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'missing.png',
isMissing: true
} satisfies MissingMediaCandidate
])
node.onWidgetChanged!.call(
node,
'image',
'uploaded.png',
'missing.png',
widget
)
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
@@ -347,6 +394,90 @@ describe('installErrorClearingHooks lifecycle', () => {
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
it('scans added-node missing models after widget values are restored', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.type = 'CheckpointLoaderSimple'
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
values: []
})
graph.add(node)
widget.value = 'fake_model.safetensors'
await Promise.resolve()
expect(useMissingModelStore().missingModelCandidates).toEqual([
expect.objectContaining({ name: 'fake_model.safetensors' })
])
})
it('scans added-node missing models before the deferred media scan', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockImplementation((_rootGraph, node) => [
{
nodeId: String(node.id),
nodeType: node.type,
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
} satisfies MissingModelCandidate
])
const mediaScan = vi
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
.mockReturnValue([])
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.type = 'CheckpointLoaderSimple'
graph.add(node)
await Promise.resolve()
expect(modelScan).toHaveBeenCalledOnce()
expect(useMissingModelStore().missingModelCandidates).toEqual([
expect.objectContaining({ name: 'fake_model.safetensors' })
])
expect(mediaScan).not.toHaveBeenCalled()
await Promise.resolve()
expect(mediaScan).toHaveBeenCalledTimes(1)
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
mediaScan.mock.invocationCallOrder[0]
)
})
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
installErrorClearingHooks(graph)
const node = new LGraphNode('LoadVideo')
node.type = 'LoadVideo'
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
values: []
})
graph.add(node)
await Promise.resolve()
node.isUploading = true
await Promise.resolve()
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
expect(mediaScan).toHaveBeenCalledOnce()
})
})
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
@@ -543,7 +674,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -611,7 +742,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
describe('realtime verification staleness guards', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
@@ -686,7 +816,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
@@ -771,7 +901,6 @@ describe('realtime verification staleness guards', () => {
describe('scan skips interior of bypassed subgraph containers', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})

View File

@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyCloudMediaCandidates
verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -155,25 +155,26 @@ function isNodeInactive(mode: number): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
function scanNodeErrorTargets(
node: LGraphNode,
scanNode: (node: LGraphNode) => void
): void {
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
for (const innerNode of collectAllNodes(node.subgraph)) {
if (innerNode.isSubgraphNode?.()) continue
if (isNodeInactive(innerNode.mode)) continue
scanSingleNodeErrors(innerNode)
scanNode(innerNode)
}
return
}
scanSingleNodeErrors(node)
scanNode(node)
}
function scanSingleNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
function getActiveExecutionId(node: LGraphNode): string | null {
if (!app.rootGraph) return null
// Skip when any enclosing subgraph is muted/bypassed. Callers only
// verify each node's own mode; entering a bypassed subgraph (via
// useGraphNodeManager replaying onNodeAdded for existing interior
@@ -181,7 +182,25 @@ function scanSingleNodeErrors(node: LGraphNode): void {
// execId means the node has no current graph (e.g. detached mid
// lifecycle) — also skip, since we cannot verify its scope.
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
return execId
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
scanNodeErrorTargets(node, scanSingleNodeErrors)
}
function scanSingleNodeErrors(node: LGraphNode): void {
scanSingleNodeModelsAndTypes(node)
scanSingleNodeMedia(node)
}
function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
if (!app.rootGraph) return
const execId = getActiveExecutionId(node)
if (!execId) return
const modelCandidates = scanNodeModelCandidates(
app.rootGraph,
@@ -204,39 +223,40 @@ function scanSingleNodeErrors(node: LGraphNode): void {
void verifyAndAddPendingModels(pendingModels)
}
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
function scanSingleNodeMedia(node: LGraphNode): void {
if (!app.rootGraph) return
if (!getActiveExecutionId(node)) return
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
// Cloud media scans return pending for asset verification. OSS scans only
// return pending for generated output media.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
}
// Check for missing node type
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const execId = getExecutionIdByNode(app.rootGraph, node)
if (execId) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
}
/**
@@ -282,7 +302,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyCloudMediaCandidates(pending)
await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
@@ -293,10 +313,23 @@ async function verifyAndAddPendingMedia(
}
}
function scanAddedNode(node: LGraphNode): void {
function scanAddedNode(
node: LGraphNode,
scanNode: (node: LGraphNode) => void
): void {
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
if (isNodeInactive(node.mode)) return
scanAndAddNodeErrors(node)
scanNodeErrorTargets(node, scanNode)
}
function scheduleAddedNodeScan(node: LGraphNode): void {
queueMicrotask(() => {
scanAddedNode(node, scanSingleNodeModelsAndTypes)
// Paste/drop upload handlers run immediately after graph.add and must set
// node.isUploading synchronously before their first await. This second
// microtask lets that upload state settle before media widgets are scanned.
queueMicrotask(() => scanAddedNode(node, scanSingleNodeMedia))
})
}
function handleNodeModeChange(
@@ -368,10 +401,12 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
// Scan pasted/duplicated nodes for missing models/media.
// Skip during loadGraphData (undo/redo/tab switch) — those are
// handled by the full pipeline or cache restore.
// Deferred to microtask because onNodeAdded fires before
// node.configure() restores widget values.
// Model and node scans use the original one-microtask deferral so pasted
// missing-model errors appear before selection-scoped tabs recalculate.
// Media gets one extra microtask so drag/drop upload handlers can mark
// transient upload state before media detection reads the widget value.
if (!ChangeTracker.isLoadingGraph) {
queueMicrotask(() => scanAddedNode(node))
scheduleAddedNodeScan(node)
}
originalOnNodeAdded?.call(this, node)

View File

@@ -33,6 +33,7 @@ export interface MenuOption {
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
isShapePicker?: boolean
}
export interface SubMenuOption {
@@ -124,8 +125,8 @@ export function useMoreOptionsMenu() {
const {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
canOpenNodeInfo,
openNodeInfo,
hasSubgraphs: hasSubgraphsComputed,
hasImageNode,
hasOutputNodesSelected,
@@ -243,8 +244,8 @@ export function useMoreOptionsMenu() {
options.push({ type: 'divider' })
// Section 4: Node properties (Node Info, Shape, Color)
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
if (canOpenNodeInfo.value) {
options.push(getNodeInfoOption(openNodeInfo))
}
if (groupContext) {
options.push(getGroupColorOptions(groupContext, bump))

View File

@@ -66,6 +66,7 @@ export function useNodeMenuOptions() {
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeSubmenu.value,
isShapePicker: true,
action: () => {}
},
{
@@ -111,10 +112,10 @@ export function useNodeMenuOptions() {
action: runBranch
})
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
const getNodeInfoOption = (openNodeInfo: () => boolean): MenuOption => ({
label: t('contextMenu.Node Info'),
icon: 'icon-[lucide--info]',
action: showNodeHelp
action: openNodeInfo
})
return {

View File

@@ -3,9 +3,11 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
@@ -13,11 +15,6 @@ import {
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
// Mock composables
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
@@ -39,6 +36,45 @@ const mockConnection = {
isNode: false
}
function createMockNodeDef() {
return new ComfyNodeDefImpl({
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
input: {},
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'nodes',
description: ''
})
}
function selectSingleNodeWithNodeDef(id: number) {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
canvasStore.$state.selectedItems = [
createMockLGraphNode({ id, type: 'TestNode' })
]
vi.mocked(nodeDefStore.fromLGraphNode).mockReturnValue(createMockNodeDef())
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingStore = useSettingStore()
const settingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': false,
...overrides
}
vi.mocked(settingStore.get).mockImplementation(
(key: string): unknown => settingValues[key]
)
}
describe('useSelectionState', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -49,14 +85,7 @@ describe('useSelectionState', () => {
createSpy: vi.fn
})
)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as ReturnType<typeof useNodeLibrarySidebarTab>)
mockSettingValues()
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
@@ -187,4 +216,83 @@ describe('useSelectionState', () => {
expect(newIsPinned).toBe(false)
})
})
describe('Node Info', () => {
test('should open the right side info panel for a selected node', () => {
const rightSidePanelStore = useRightSidePanelStore()
selectSingleNodeWithNodeDef(8)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(true)
openNodeInfo()
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
})
test('should not open the right side panel for multiple selected nodes', () => {
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.$state.selectedItems = [
createMockLGraphNode({ id: 9, type: 'TestNode' }),
createMockLGraphNode({ id: 10, type: 'TestNode' })
]
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
openNodeInfo()
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
test('should open the right side info panel when new menu uses the legacy node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
selectSingleNodeWithNodeDef(11)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(true)
const didOpen = openNodeInfo()
expect(didOpen).toBe(true)
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
})
test('should not open node info when legacy menu uses the new node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
selectSingleNodeWithNodeDef(12)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
const didOpen = openNodeInfo()
expect(didOpen).toBe(false)
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
test('should not open node info when legacy menu uses the legacy node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
selectSingleNodeWithNodeDef(13)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
const didOpen = openNodeInfo()
expect(didOpen).toBe(false)
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,14 +1,12 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
@@ -25,9 +23,8 @@ export interface NodeSelectionState {
export function useSelectionState() {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { selectedItems } = storeToRefs(canvasStore)
@@ -64,7 +61,7 @@ export function useSelectionState() {
)
const hasAny3DNodeSelected = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
const enable3DViewer = settingStore.get('Comfy.Load3D.3DViewerEnable')
return (
selectedNodes.value.length === 1 &&
selectedNodes.value.some(isLoad3dNode) &&
@@ -98,34 +95,24 @@ export function useSelectionState() {
const computeSelectionFlags = (): NodeSelectionState =>
computeSelectionStatesFromNodes(selectedNodes.value)
/** Toggle node help sidebar/panel for the single selected node (if any). */
const showNodeHelp = () => {
const def = nodeDef.value
if (!def) return
const canOpenNodeInfo = computed(
() =>
Boolean(nodeDef.value) &&
settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const isSidebarActive =
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
const currentHelpNode = nodeHelpStore.currentHelpNode
const isSameNodeHelpOpen =
isSidebarActive &&
nodeHelpStore.isHelpOpen &&
currentHelpNode?.nodePath === def.nodePath
if (isSameNodeHelpOpen) {
nodeHelpStore.closeHelp()
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
return
}
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
nodeHelpStore.openHelp(def)
const openNodeInfo = () => {
if (!canOpenNodeInfo.value) return false
rightSidePanelStore.openPanel('info')
return true
}
return {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
canOpenNodeInfo,
openNodeInfo,
hasAny3DNodeSelected,
hasAnySelection,
hasSingleSelection,

View File

@@ -73,14 +73,12 @@ export const useNodeDragAndDrop = <T>(
return true
}
const baseUri = e?.dataTransfer?.getData('text/uri-list') ?? ''
const uri = URL.parse(baseUri, location.href)
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
if (!uri || uri.origin !== location.origin) return false
try {
const resp = await fetch(uri)
const fileName =
uri?.searchParams?.get('filename') ?? baseUri.split('/').at(-1)
const fileName = uri?.searchParams?.get('filename')
if (!fileName || !resp.ok) return false
const blob = await resp.blob()

View File

@@ -54,8 +54,8 @@ function createMockNode(): LGraphNode {
})
}
function createFile(name = 'test.png'): File {
return new File(['data'], name, { type: 'image/png' })
function createFile(name = 'test.png', type = 'image/png'): File {
return new File(['data'], name, { type })
}
function successResponse(name: string, subfolder?: string) {
@@ -95,15 +95,21 @@ describe('useNodeImageUpload', () => {
})
})
it('sets isUploading true during upload and false after', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
it.for([
{ mediaType: 'image', filename: 'test.png', mimeType: 'image/png' },
{ mediaType: 'video', filename: 'clip.mp4', mimeType: 'video/mp4' }
])(
'sets isUploading true during $mediaType upload and false after',
async ({ filename, mimeType }) => {
mockFetchApi.mockResolvedValueOnce(successResponse(filename))
const promise = capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(true)
const promise = capturedDragOnDrop([createFile(filename, mimeType)])
expect(node.isUploading).toBe(true)
await promise
expect(node.isUploading).toBe(false)
})
await promise
expect(node.isUploading).toBe(false)
}
)
it('clears node.imgs on upload start', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))

View File

@@ -1,83 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionBarButton } from '@/types/comfy'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
const registerExtension = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
})
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
}))
describe('cloudFeedbackTopbarButton', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetModules()
registerExtension.mockReset()
distribution.isCloud = false
distribution.isNightly = false
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
})
function getRegisteredButtons(): ActionBarButton[] {
expect(registerExtension).toHaveBeenCalledTimes(1)
const extension = registerExtension.mock.calls[0]?.[0] as {
actionBarButtons: ActionBarButton[]
}
return extension.actionBarButtons
}
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
tabBarLayout.value = 'Legacy'
distribution.isCloud = true
await import('./cloudFeedbackTopbarButton')
const buttons = getRegisteredButtons()
expect(buttons).toHaveLength(1)
buttons[0].onClick?.()
expect(openSpy).toHaveBeenCalledTimes(1)
const [url, target, features] = openSpy.mock.calls[0]
expect(url).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
)
expect(target).toBe('_blank')
expect(features).toBe('noopener,noreferrer')
})
it('only registers the action bar button when the tab bar is Legacy', async () => {
tabBarLayout.value = 'Default'
await import('./cloudFeedbackTopbarButton')
expect(getRegisteredButtons()).toEqual([])
})
})

Some files were not shown because too many files have changed in this diff Show More