Compare commits

...

11 Commits

Author SHA1 Message Date
Benjamin Lu
b5a110de76 Merge branch 'main' into codex/website-github-stars-once 2026-05-07 03:23:10 -07:00
pythongosssss
997501d8fb test: add e2e test for metadata parsing on workflow load (#11522)
## Summary

Adds e2e testing to ensure workflows are correctly loaded from each of
the supported file types

## Changes

- **What**: 
- add png generation
- add mime types for missing files
- add test that loads file and ensures node is present

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11522-test-add-e2e-test-for-metadata-parsing-on-workflow-load-3496d73d36508101ad67d24af1810cec)
by [Unito](https://www.unito.io)
2026-05-07 09:58:52 +00:00
Christian Byrne
ab6e5ba094 feat: boost SaveImageAdvanced node frequency for search ranking (#11853)
*PR Created by the Glary-Bot Agent*

---

Adds an entry for the new `SaveImageAdvanced` node to
`public/assets/sorted-custom-node-map.json` with the same frequency stat
(1762) as the existing `SaveImage` node, so the new Save Image node
ranks at the top of search results when typing "save" — matching the
original node's behavior.

Context: the new Save Image node ([Notion
spec](https://www.notion.so/comfy-org/Save-Image-94a77c506ce145fc9b8c477c52091a04))
replaces/deprecates the original `SaveImage`. Search ranking uses the
static node frequency map; the new node had no entry and was therefore
ranked at frequency 0. Mirroring the original's stat is the manual-boost
approach discussed in the thread.

## Changes
- `public/assets/sorted-custom-node-map.json`: add `"SaveImageAdvanced":
1762` directly after `"SaveImage": 1762` to preserve descending sort
order.

## Verification
- `pnpm typecheck`, `pnpm lint`, and `pnpm format` all pass via
lint-staged on commit.
- JSON validated and entry placement confirmed (position 4, between
`SaveImage` and `VAEDecode`).
- Review (oracle) ran clean: 0 critical / 0 warning / 0 suggestion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11853-feat-boost-SaveImageAdvanced-node-frequency-for-search-ranking-3546d73d36508168b058d9d750fc3c56)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 09:45:25 +00:00
Yourz
2322a5a497 fix: use webm video for VFX use case right asset (#12040)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces `right1.webp` with `right1.webm` in the VFX panel of
`UseCaseSection`. `BlobMedia` already auto-detects `.webm` URLs and
mounts a `<video>` element (with the `.webp` as poster), so this single
URL swap is the only change required — matching the pattern used by the
other 4 use-case panels.

## Files changed

- `apps/website/src/components/home/UseCaseSection.vue` — swap
`right1.webp` → `right1.webm`.

## Verification

- `pnpm exec nx run website:typecheck` — clean
- `pnpm exec eslint` on changed file — clean
- `pnpm exec oxfmt --check` — clean
- pre-commit lint-staged hooks — passed

## Reviewer note (Oracle finding)

VFX is the default active panel, so the homepage's initial right-rail
asset moves from ~131 KB `.webp` to ~4.1 MB `.webm`. Behaviorally
consistent with the other 4 panels (which already use `.webm`), but
worth confirming whether `right1.webm` should be re-encoded smaller on
the CDN before promoting this PR out of draft.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12040-fix-use-webm-video-for-VFX-use-case-right-asset-3596d73d365081829976f37b733840f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-07 09:32:53 +00:00
Dante
0bc951fd12 fix: clarify unsaved-changes modal buttons and fix sign-out 3-state (#11669)
## Summary

The dirtyClose modal had three buttons (`Cancel | No | Save`) and the
sign-out flow collapsed two distinct outcomes (deny vs. dismiss) into a
single early return — so today clicking "No" *cancels* sign-out instead
of signing out without saving, and clicking "Save" never actually saves
before logging out. This PR drops `Cancel` for `dirtyClose`, gives each
caller a context-specific deny label, and fixes the sign-out 3-state
handling.

- Fixes
[FE-419](https://linear.app/comfyorg/issue/FE-419/unsaved-changes-modal-uses-confusing-button-labels)

## Changes

- **What**:
- `ConfirmationDialogContent.vue`: hide `Cancel` for
`type='dirtyClose'`; add `denyLabel?: string` prop; autofocus `Save`
(preserves work on Enter).
  - `dialogService.confirm()`: accept and forward `denyLabel`.
- `useAuthActions.logout`: handle `null` (cancel) / `false` (sign out
anyway, no save) / `true` (save each modified workflow, then logout)
distinctly. Pass `denyLabel: 'Sign out anyway'`.
  - `workflowService.closeWorkflow`: pass `denyLabel: 'Close anyway'`.
- i18n: add `auth.signOut.signOutAnyway` and
`sideToolbar.workflowTab.closeAnyway`.
- **Breaking**: none. The `denyLabel` prop is optional and falls back to
`g.no`.

## Review Focus

- The "Save" branch in `useAuthActions.logout` now iterates
`workflowStore.modifiedWorkflows` and awaits
`useWorkflowService().saveWorkflow(workflow)` for each before calling
`authStore.logout()`. The close-tab path
(`workflowService.closeWorkflow`) was already correct — only the
sign-out path needed the same shape.
- `ConfirmationDialogContent` autofocus moves from `Cancel` (gone for
`dirtyClose`) to `Save`. The dialog is still dismissable via ESC /
outside-click, which routes through `dialogComponentProps.onClose →
resolve(null)` — sign-out and close-tab both treat `null` as cancel.
- Out of scope: the native browser `beforeunload` warning
(`UnloadWindowConfirmDialog.vue`) is a separate flow and never reaches
the in-app modal.

## Tests

- Unit (`useAuthActions.test.ts`, new): logout handles `null` / `false`
/ `true` / no-modified-workflows; saves *every* modified workflow before
`authStore.logout`; passes `denyLabel='Sign out anyway'`.
- Unit (`ConfirmationDialogContent.test.ts`): Cancel hidden for
`dirtyClose`; custom `denyLabel` rendered; falls back to `g.no` when
omitted.
- E2E (`workflowTabs.spec.ts`): modified-tab close shows `Close anyway`
(not `No`) and no `Cancel`; clicking `Close anyway` removes the tab; ESC
keeps the tab.

## screenshot

### AS IS 

<img width="816" height="379" alt="Screenshot 2026-04-27 at 5 40 19 PM"
src="https://github.com/user-attachments/assets/a8e39403-bf72-455a-8d86-6ceb1f94ac85"
/>

<img width="923" height="396" alt="Screenshot 2026-04-27 at 5 40 38 PM"
src="https://github.com/user-attachments/assets/08031c7c-b3a6-45d7-a4dc-5dcb4e63cfa0"
/>


### TO BE 

<img width="1661" height="872" alt="Screenshot 2026-04-27 at 5 43 40 PM"
src="https://github.com/user-attachments/assets/b89d160b-be66-450e-981e-32b1591f6841"
/>


<img width="1488" height="584" alt="Screenshot 2026-04-27 at 5 44 21 PM"
src="https://github.com/user-attachments/assets/b3a141a7-1f3b-4f25-85a9-49529229c28b"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11669-fix-clarify-unsaved-changes-modal-buttons-and-fix-sign-out-3-state-34f6d73d365081bf8afad8e146b3b990)
by [Unito](https://www.unito.io)
2026-05-07 02:49:02 -07:00
Christian Byrne
0446ca7a18 fix: route default topbar feedback button to Typeform (#11863)
*PR Created by the Glary-Bot Agent*

---

## Summary

PR #10890 routed the legacy action bar feedback button and the Help
Center feedback item to the nightly Typeform survey, but the **default
topbar feedback button** in `WorkflowTabs.vue` still called
`buildFeedbackUrl()` and opened Zendesk. Since `Comfy.UI.TabBarLayout`
defaults to `Default` (not `Legacy`), most Cloud/Nightly users were
clicking the WorkflowTabs button and never reaching the Typeform survey
— explaining the lack of survey responses.

## Changes

- Added a shared `buildFeedbackTypeformUrl(source)` helper in
`platform/support/config.ts` that tags the survey URL with:
- `distribution`: `ccloud` / `oss-nightly` / `oss` (preserves the
build-tagging the old `buildFeedbackUrl()` sent to Zendesk so responses
stay segmented)
- `source`: `topbar` / `action-bar` / `help-center` (identifies which UI
entry point launched the survey)

Tags are passed via the URL fragment (Typeform's hidden-field
convention), so they reach the survey but are never sent to the server
in the request line.
- `WorkflowTabs.vue`: replaced `buildFeedbackUrl()` with
`buildFeedbackTypeformUrl('topbar')`.
- `cloudFeedbackTopbarButton.ts` and `HelpCenterMenuContent.vue`: use
the shared builder with their respective source labels instead of inline
URL literals.
- Removed the now-unused `buildFeedbackUrl()` and
`ZENDESK_FEEDBACK_FORM_ID` (knip-clean). `buildSupportUrl()` is
preserved — `Comfy.ContactSupport` (the Help Center "Help" item) still
routes to Zendesk as before.
- Added unit tests for the builder, the WorkflowTabs feedback button,
the legacy action bar button, and the Help Center feedback item
(covering both the Cloud/Nightly Typeform path and the OSS
`Comfy.ContactSupport` fallback).

## Verification

- `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm knip`: clean (one
pre-existing unrelated lint warning in `useWorkspaceBilling.test.ts`)
- `pnpm test:unit` (impacted scope): 506/506 passing, including 13 new
tests

## Review Focus

- Cloud/Nightly gating in `WorkflowTabs.vue` (`v-if="isCloud ||
isNightly"`) is unchanged and matches PR #10890's gating philosophy.
- The Help Center "Help" item and `Comfy.ContactSupport` command
intentionally still route to Zendesk — feedback ≠ support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11863-fix-route-default-topbar-feedback-button-to-Typeform-3556d73d3650815fb446dac33095d4be)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 02:45:05 -07:00
Terry Jia
653ee48444 FE-557: fix(painter): responsive label layout + correct resize min-height (#12025)
## Summary

- WidgetPainter: stack label above widget when controls width < 350px,
side-by-side otherwise; labels are always rendered (no longer hidden
when narrow).
- useNodeResize: re-measure the node's intrinsic min content height on
every pointermove instead of capturing it once at drag start, so the
height clamp tracks widgets whose controls reflow taller as width
shrinks (e.g. painter switching to compact layout). Without this, the
node visually sticks at its current height and the user has to release
and grab the corner again to free it.
- Add unit tests for both changes.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/74889ad5-63a7-439f-b8e4-0185ed95327f


after


https://github.com/user-attachments/assets/bca77c36-2f90-4685-8603-f8f9c02abe77

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12025-FE-557-fix-painter-responsive-label-layout-correct-resize-min-height-3586d73d365081cf9036f7d52bfabe6c)
by [Unito](https://www.unito.io)
2026-05-07 04:11:51 -04:00
Benjamin Lu
e798608843 test: type github stars fetch mocks 2026-05-06 08:01:16 -07:00
Benjamin Lu
4f94104faf test: avoid duplicating github stars fallback 2026-05-06 07:58:26 -07:00
Benjamin Lu
ca48ec86eb fix: fall back to latest github star count 2026-05-06 07:56:02 -07:00
Benjamin Lu
72b5f6be68 fix: fetch website GitHub stars once per build 2026-05-04 20:09:21 -07:00
38 changed files with 1748 additions and 110 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

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.webp'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
},
{
label: t('useCase.advertising', locale),

View File

@@ -5,7 +5,7 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { fetchGitHubStarsForBuild, formatStarCount } from '../utils/github'
interface Props {
title: string
@@ -30,7 +30,7 @@ const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const rawStars = await fetchGitHubStarsForBuild()
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const gtmId = 'GTM-NP9JM6K7'

View File

@@ -1,11 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
import {
fetchGitHubStars,
fetchGitHubStarsForBuild,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -16,17 +22,47 @@ describe('fetchGitHubStars', () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
await expect(fetchGitHubStars()).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
await expect(fetchGitHubStars()).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes build-time star fetches within a single process', async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b] = await Promise.all([
fetchGitHubStarsForBuild(fetchImpl),
fetchGitHubStarsForBuild(fetchImpl)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('falls back to the last known star count for build-time fetch failures', async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response(null, { status: 403 })
)
const fallback = await fetchGitHubStarsForBuild(fetchImpl)
expect(Number.isSafeInteger(fallback)).toBe(true)
expect(fallback).toBeGreaterThan(0)
})
})
describe('formatStarCount', () => {

View File

@@ -1,17 +1,35 @@
const GITHUB_REPO_API_URL = 'https://api.github.com/repos/Comfy-Org/ComfyUI'
// Fetched from GitHub on 2026-05-06.
const GITHUB_STARS_FALLBACK = 111_605
let inflight: Promise<number> | undefined
export function resetGitHubStarsFetcherForTests(): void {
inflight = undefined
}
export function fetchGitHubStarsForBuild(
fetchImpl: typeof fetch = fetch
): Promise<number> {
inflight ??= fetchGitHubStars(fetchImpl).then(
(stars) => stars ?? GITHUB_STARS_FALLBACK
)
return inflight
}
export async function fetchGitHubStars(
owner: string,
repo: string
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
const res = await fetchImpl(GITHUB_REPO_API_URL, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
const data: unknown = await res.json()
return readStargazerCount(data)
} catch {
return null
}
@@ -42,3 +60,9 @@ function readGitHubStarsOverride(): number | undefined {
return count
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
const count = (data as { stargazers_count?: unknown }).stargazers_count
return typeof count === 'number' ? count : null
}

View File

@@ -1,4 +1,5 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -13,6 +14,7 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -22,13 +24,14 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
const evaluateParams: {
dropPosition: Position
@@ -39,12 +42,22 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
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 }
)
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -148,6 +161,13 @@ 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

@@ -7,6 +7,9 @@ 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,3 +1,7 @@
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,62 @@
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

@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls collapse to single column in compact mode', async ({
test('Controls stack label above widget 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 two-column layout'
'tool label should be visible in wide 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']
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
'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)
})
test('Multiple sequential strokes at different positions all accumulate', async ({

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -188,4 +189,79 @@ 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

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

View File

@@ -19,6 +19,7 @@ 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__')
@@ -115,6 +116,15 @@ 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()
@@ -167,6 +177,7 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
generate_png()
generate_webp()
generate_avif()
generate_flac()

View File

@@ -1,4 +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 { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -42,4 +43,43 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -55,7 +55,7 @@
</div>
<Button
v-if="type !== 'info'"
v-if="type !== 'info' && type !== 'dirtyClose'"
variant="secondary"
autofocus
@click="onCancel"
@@ -86,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ $t('g.no') }}
{{ denyLabel ?? $t('g.no') }}
</Button>
<Button @click="onConfirm">
<Button autofocus @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -131,6 +131,7 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

View File

@@ -0,0 +1,164 @@
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,6 +163,7 @@ 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'
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
trackResourceClick('help_feedback', isCloud || isNightly)
if (isCloud || isNightly) {
window.open(
'https://form.typeform.com/to/q7azbWPi',
buildFeedbackTypeformUrl('help-center'),
'_blank',
'noopener,noreferrer'
)

View File

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

View File

@@ -23,6 +23,7 @@
<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

@@ -0,0 +1,186 @@
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 { buildFeedbackUrl } from '@/platform/support/config'
import { buildFeedbackTypeformUrl } 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,9 +152,12 @@ const isIntegratedTabBar = computed(
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
window.open(
buildFeedbackTypeformUrl('topbar'),
'_blank',
'noopener,noreferrer'
)
}
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -0,0 +1,195 @@
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,6 +9,7 @@ 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'
@@ -53,14 +54,30 @@ export const useAuthActions = () => {
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
if (workflowStore.modifiedWorkflows.length > 0) {
const modifiedWorkflows = workflowStore.modifiedWorkflows
if (modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
type: 'dirtyClose',
denyLabel: t('auth.signOut.signOutAnyway')
})
if (!confirmed) return
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 })
)
}
}
}
}
await authStore.logout()

View File

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

View File

@@ -1,17 +1,20 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi'
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
window.open(
buildFeedbackTypeformUrl('action-bar'),
'_blank',
'noopener,noreferrer'
)
}
}
]

View File

@@ -979,6 +979,7 @@
"dirtyCloseTitle": "Save Changes?",
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
"dirtyCloseHint": "Hold Shift to close without prompt",
"dirtyCloseAnyway": "Close anyway",
"confirmOverwriteTitle": "Overwrite existing file?",
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
"workflowTreeType": {
@@ -2211,7 +2212,9 @@
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?",
"signOutAnyway": "Sign out anyway",
"saveFailed": "Sign-out cancelled because saving \"{workflow}\" failed."
},
"passwordUpdate": {
"success": "Password Updated",

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
}))
describe('buildFeedbackTypeformUrl', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
})
async function build(source: 'topbar' | 'action-bar' | 'help-center') {
vi.resetModules()
const { buildFeedbackTypeformUrl } = await import('./config')
return buildFeedbackTypeformUrl(source)
}
it('tags Cloud builds with distribution=ccloud', async () => {
distribution.isCloud = true
expect(await build('topbar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
)
})
it('tags Nightly builds with distribution=oss-nightly', async () => {
distribution.isNightly = true
expect(await build('action-bar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
)
})
it('tags OSS builds with distribution=oss', async () => {
expect(await build('help-center')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
)
})
it('uses a URL fragment so distribution and source are not sent to the server', async () => {
distribution.isCloud = true
const url = new URL(await build('topbar'))
expect(url.search).toBe('')
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
})
})

View File

@@ -15,7 +15,7 @@ const ZENDESK_FIELDS = {
} as const
/**
* Gets the distribution identifier for Zendesk tracking.
* Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
@@ -25,17 +25,22 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Builds the feedback form URL with the appropriate distribution tag.
* Builds the feedback Typeform URL tagged with the current build distribution
* and the UI source that opened it. Tags are passed via the URL fragment
* (Typeform's hidden-field convention) so survey responses can be segmented
* by distribution (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackUrl(): string {
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
distribution: getDistribution(),
source
})
return `${SUPPORT_BASE_URL}?${params.toString()}`
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
/**

View File

@@ -418,24 +418,51 @@ describe('useWorkflowService', () => {
})
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
await useWorkflowService().saveWorkflow(workflow)
const result = await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(true)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('should call saveWorkflowAs for temporary workflows', async () => {
it('should return false when temporary workflow save is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
await useWorkflowService().saveWorkflow(workflow)
const result = await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
beforeEach(() => {
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
it('keeps a temporary workflow open when Save As is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
workflow.isModified = true
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
mockConfirm.mockResolvedValue(true)
const closed = await service.closeWorkflow(workflow)
expect(closed).toBe(false)
expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
})
})
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let existingWorkflow: LoadedComfyWorkflow

View File

@@ -174,40 +174,39 @@ export const useWorkflowService = () => {
* Save a workflow
* @param workflow The workflow to save
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
const saveWorkflow = async (workflow: ComfyWorkflow): Promise<boolean> => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
'/' +
appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return
}
await deleteWorkflow(existing, true)
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
return await saveWorkflowAs(workflow)
}
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory + '/' + appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return true
}
await deleteWorkflow(existing, true)
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
return true
}
/**
@@ -284,13 +283,15 @@ export const useWorkflowService = () => {
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint
hint: options.hint,
denyLabel: t('sideToolbar.workflowTab.dirtyCloseAnyway')
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
await saveWorkflow(workflow)
const saved = await saveWorkflow(workflow)
if (!saved) return false
}
}

View File

@@ -34,11 +34,21 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
})
}))
const snapState = vi.hoisted(() => ({
shouldSnap: false,
applySnapToPosition: (pos: { x: number; y: number }) => pos,
applySnapToSize: (size: { width: number; height: number }) => size
}))
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({
useNodeSnap: () => ({
shouldSnap: vi.fn(() => false),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos),
applySnapToSize: vi.fn((size: { width: number; height: number }) => size)
shouldSnap: vi.fn(() => snapState.shouldSnap),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) =>
snapState.applySnapToPosition(pos)
),
applySnapToSize: vi.fn((size: { width: number; height: number }) =>
snapState.applySnapToSize(size)
)
})
}))
@@ -149,6 +159,9 @@ describe('useNodeResize', () => {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
snapState.shouldSnap = false
snapState.applySnapToPosition = (pos) => pos
snapState.applySnapToSize = (size) => size
callback = vi.fn<ResizeCallback>()
nodeElement = createMockNodeElement()
@@ -273,4 +286,230 @@ describe('useNodeResize', () => {
expect(payload.position!.y).toBe(450)
})
})
describe('dynamic content height (re-measured per move)', () => {
function makeReflowingElement(
width: number,
height: number,
getMinContentHeight: () => number
): HTMLElement {
const element = document.createElement('div')
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
const nodeHeight = element.style.getPropertyValue('--node-height')
const h = nodeHeight === '0px' ? getMinContentHeight() : height
return {
width,
height: h,
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: h,
toJSON: () => {}
} as DOMRect
}
return element
}
async function setupDynamic(getMinContentHeight: () => number) {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
const cb = vi.fn<ResizeCallback>()
const el = makeReflowingElement(300, 400, getMinContentHeight)
const h = createMockHandle(el)
const { useNodeResize } = await import('./useNodeResize')
const { startResize } = useNodeResize(cb)
return { cb, el, handle: h, startResize }
}
it('uses the latest measured content height when content reflows taller', async () => {
let currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'SE')
// First move: clamp uses initial minContentHeight = 150
simulateMove(0, -300)
const firstPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(firstPayload.size.height).toBe(150)
// Content reflows taller (e.g. painter switches to compact layout)
currentMinHeight = 280
// Second move at the same position must reflect the new minimum,
// not the value captured at drag start.
simulateMove(0, -300)
const secondPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(secondPayload.size.height).toBe(280)
})
it('also re-measures for N corners and updates the y-position clamp', async () => {
let currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'NW')
simulateMove(0, 500)
const firstPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(firstPayload.size.height).toBe(150)
expect(firstPayload.position!.y).toBe(450) // 200 + 400 - 150
currentMinHeight = 220
simulateMove(0, 500)
const secondPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(secondPayload.size.height).toBe(220)
expect(secondPayload.position!.y).toBe(380) // 200 + 400 - 220
})
it('stops responding to pointermove after pointerup', async () => {
const currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'SE')
simulateMove(20, 20)
const callsBeforeUp = cb.mock.calls.length
const upEvent = createPointerEvent('pointerup', { pointerId: 1 })
eventHandlers.pointerup?.(upEvent)
// Subsequent moves should be ignored after cleanup
simulateMove(40, 40)
expect(cb.mock.calls.length).toBe(callsBeforeUp)
})
it('handles releasePointerCapture throwing without breaking cleanup', async () => {
const { cb, el, handle: h, startResize } = await setupDynamic(() => 150)
h.releasePointerCapture = vi.fn(() => {
throw new Error('already released')
})
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
const upEvent = createPointerEvent('pointerup', { pointerId: 1 })
expect(() => eventHandlers.pointerup?.(upEvent)).not.toThrow()
// Further moves are ignored — cleanup still ran.
const callsAfterUp = cb.mock.calls.length
simulateMove(50, 50)
expect(cb.mock.calls.length).toBe(callsAfterUp)
expect(el).toBeDefined()
})
it('applies snap-to-grid on SE (size only)', async () => {
snapState.shouldSnap = true
snapState.applySnapToSize = ({ width, height }) => ({
width: Math.round(width / 10) * 10,
height: Math.round(height / 10) * 10
})
const { cb, handle: h, startResize } = await setupDynamic(() => 50)
startResizeAt(startResize, h, 'SE')
simulateMove(53, 27)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.size.width).toBe(350) // 353 -> 350
expect(payload.size.height).toBe(430) // 427 -> 430
expect(payload.position).toBeUndefined()
})
it('applies snap-to-grid on NW (position + size compensation)', async () => {
snapState.shouldSnap = true
// Snap position down to nearest 10
snapState.applySnapToPosition = ({ x, y }) => ({
x: Math.floor(x / 10) * 10,
y: Math.floor(y / 10) * 10
})
snapState.applySnapToSize = ({ width, height }) => ({
width: Math.round(width / 10) * 10,
height: Math.round(height / 10) * 10
})
const { cb, handle: h, startResize } = await setupDynamic(() => 50)
startResizeAt(startResize, h, 'NW')
// delta: x=-53, y=-27 -> raw newX=47, newY=173
// applySnapToPosition floors -> {40, 170}
// size compensated: width += 47-40=7 (-> 360), height += 173-170=3 (-> 430)
// applySnapToSize rounds -> 360, 430
simulateMove(-53, -27)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.position).toEqual({ x: 40, y: 170 })
expect(payload.size).toEqual({ width: 360, height: 430 })
})
it('restores --node-height after measuring (does not clobber state)', async () => {
const { el, handle: h, startResize } = await setupDynamic(() => 150)
el.style.setProperty('--node-height', '400px')
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
// Probe value should be reverted, not left at '0px'
expect(el.style.getPropertyValue('--node-height')).toBe('400px')
})
it('measures with the candidate width applied (responsive breakpoint frame)', async () => {
// Simulate a responsive widget: when width < 350, content reflows to
// 280; when width >= 350, content fits in 150.
const breakpointAwareElement = (() => {
const element = document.createElement('div')
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
const nodeHeight = element.style.getPropertyValue('--node-height')
if (nodeHeight === '0px') {
const widthVar = element.style.getPropertyValue('--node-width')
const probedWidth = parseFloat(widthVar) || 300
const minH = probedWidth < 350 ? 280 : 150
return { width: probedWidth, height: minH } as DOMRect
}
return { width: 300, height: 400 } as DOMRect
}
return element
})()
const cb = vi.fn<ResizeCallback>()
const h = createMockHandle(breakpointAwareElement)
const { useNodeResize } = await import('./useNodeResize')
const { startResize } = useNodeResize(cb)
// Start at width=300 (still narrow side, but the breakpoint logic
// matters when the user attempts to shrink toward narrow on this frame).
breakpointAwareElement.style.setProperty('--node-width', '400px')
startResizeAt(startResize, h, 'SE')
// First move drives newWidth to 340 (below breakpoint). Probe must use
// 340, not the DOM's currently-applied 400, to return 280.
simulateMove(-60, -300)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.size.height).toBe(280)
})
it('restores --node-width after probing (does not clobber state)', async () => {
const { el, handle: h, startResize } = await setupDynamic(() => 150)
el.style.setProperty('--node-width', '350px')
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
expect(el.style.getPropertyValue('--node-width')).toBe('350px')
})
})
})

View File

@@ -59,10 +59,17 @@ export function useNodeResize(
height: rect.height / scale
}
const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-height', '0px')
const minContentHeight = nodeElement.getBoundingClientRect().height / scale
nodeElement.style.setProperty('--node-height', savedNodeHeight || '')
const measureMinContentHeight = (candidateWidth: number) => {
const savedWidth = nodeElement.style.getPropertyValue('--node-width')
const savedHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-width', `${candidateWidth}px`)
nodeElement.style.setProperty('--node-height', '0px')
const measured = nodeElement.getBoundingClientRect().height
nodeElement.style.setProperty('--node-height', savedHeight || '')
nodeElement.style.setProperty('--node-width', savedWidth || '')
const currentScale = transformState.camera.z || 1
return measured / currentScale
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
const startPosition: Point = nodeLayout
@@ -165,6 +172,12 @@ export function useNodeResize(
}
newWidth = minWidth
}
// Re-measure on each move with the candidate width applied: widget
// content (e.g. painter controls) can re-flow taller as width shrinks,
// raising the true minimum. Probing with newWidth — not the DOM's
// current width — keeps the clamp accurate on the frame that crosses
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
newY =

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

View File

@@ -42,6 +42,31 @@ export type ConfirmationDialogType =
| 'reinstall'
| 'info'
interface BaseConfirmOptions {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}
type ConfirmOptions = BaseConfirmOptions &
(
| {
/** Pre-configured dialog type */
type: 'dirtyClose'
/** Override the deny button label. Defaults to `g.no`. */
denyLabel?: string
}
| {
/** Pre-configured dialog type */
type?: Exclude<ConfirmationDialogType, 'dirtyClose'>
denyLabel?: never
}
)
/**
* Minimal interface for execution error dialogs.
* Satisfied by both ExecutionErrorWsMessage (WebSocket) and ExecutionError (Jobs API).
@@ -244,18 +269,9 @@ export const useDialogService = () => {
message,
type = 'default',
itemList = [],
hint
}: {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Pre-configured dialog type */
type?: ConfirmationDialogType
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}): Promise<boolean | null> {
hint,
denyLabel
}: ConfirmOptions): Promise<boolean | null> {
return new Promise((resolve) => {
const options: ShowDialogOptions = {
key: 'global-prompt',
@@ -266,7 +282,8 @@ export const useDialogService = () => {
type,
itemList,
onConfirm: resolve,
hint
hint,
denyLabel
},
dialogComponentProps: {
onClose: () => resolve(null)