Compare commits

..

8 Commits

Author SHA1 Message Date
dante01yoon
076a6d95f1 fix: include focusMode in splitter refresh key to prevent panel resize 2026-04-16 10:57:53 +09:00
pythongosssss
a8e1fa8bef test: add regression test for WEBP RIFF padding (#8527) (#11267)
## Summary

Add a regression test for #8527 (handle RIFF padding for odd-sized WEBP
chunks). The fix added + (chunk_length % 2) to the chunk-stride
calculation in getWebpMetadata so EXIF chunks following an odd-sized
chunk are still located correctly. There was no existing unit test
covering getWebpMetadata, so without a regression test the fix could
silently break in a future
  refactor. 

## Changes

- **What**: 
- New unit test file src/scripts/pnginfo.test.ts covering
getWebpMetadata's RIFF chunk traversal.
- Helpers build a minimal in-memory WEBP with one VP8 chunk of
configurable length followed by an EXIF chunk encoding workflow:<json>.
- Odd-length case (regression for #8527): without the % 2 padding
adjustment, the parser walks one byte short and returns {}.
- Even-length case: guards against an over-correction that always adds
1.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11267-test-add-regression-test-for-WEBP-RIFF-padding-8527-3436d73d36508117a66edf3cb108ded0)
by [Unito](https://www.unito.io)
2026-04-15 18:14:49 +00:00
pythongosssss
83ceef8cb3 test: add regression test for non-string serverLogs (#8460) (#11268)
## Summary

Add a regression test for #8460 (handle non-string `serverLogs` in error
report). The fix added `typeof error.serverLogs === 'string' ? ... :
JSON.stringify(...)` in `errorReportUtil.ts` so object-shaped logs no
longer render as `[object Object]`. There was no existing unit test for
`generateErrorReport`, so this regression could silently return.

## Changes

- **What**: New unit test file `src/utils/errorReportUtil.test.ts`
covering `generateErrorReport`'s `serverLogs` rendering.
- String case: verifies plain-string logs still appear verbatim and
`[object Object]` is absent.
- Object case (regression for #8460): verifies object logs are
JSON-stringified instead of coerced to `[object Object]`.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11268-test-add-regression-test-for-non-string-serverLogs-8460-3436d73d36508195a32fc559ab7ce5bb)
by [Unito](https://www.unito.io)
2026-04-15 18:14:17 +00:00
Christian Byrne
4885ef856c [chore] Update Comfy Registry API types from comfy-api@113318d (#11261)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 113318d
- Generated on: 2026-04-15T04:26:33Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11261-chore-Update-Comfy-Registry-API-types-from-comfy-api-113318d-3436d73d3650816784d4efd98d6a665a)
by [Unito](https://www.unito.io)

Co-authored-by: bigcat88 <13381981+bigcat88@users.noreply.github.com>
2026-04-15 11:16:10 -07:00
Christian Byrne
873a75d607 test: add unit tests for usePainter composable (#11137)
## Summary

Add 25 behavioral unit tests for `usePainter` composable, bringing
coverage from 0% to ~35% lines / ~57% functions.

## Changes

- **What**: New test file `src/composables/painter/usePainter.test.ts`
covering widget sync, settings persistence, canvas sizing, brush display
scaling, serialization, restore, pointer event guards, and cursor
visibility.

## Review Focus

- Mock patterns: singleton factory mocks for stores, wrapper component
for lifecycle hooks
- Test coverage prioritization: focused on mount-time sync, reactive
watchers, and computed behavior rather than canvas pixel output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11137-test-add-unit-tests-for-usePainter-composable-33e6d73d36508147bde7e9c349c743ca)
by [Unito](https://www.unito.io)
2026-04-15 11:13:31 -07:00
pythongosssss
ecb6fbe8fb test: Add waitForWorkflowIdle & remove redundant nextFrame (#11264)
## Summary

More cleanup and reliability

## Changes

- **What**: 
- Add wait for idle
- Remove redundant nextFrames

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11264-test-Add-waitForWorkflowIdle-remove-redundant-nextFrame-3436d73d3650812c837ac7503ce0947b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-15 16:52:41 +00:00
Alexander Brown
52ccd9ed1a refactor: internalize nextFrame() into fixture/helper methods (#11166)
## Summary

Internalize `nextFrame()` calls into fixture/helper methods so spec
authors don't need to remember to call it after common operations.
`nextFrame()` waits for one `requestAnimationFrame` (~16ms) — an extra
call is always safe, making this a low-risk refactor.

## Changes

### Phase 1: `SettingsHelper.setSetting()`
`setSetting()` now calls `nextFrame()` internally. Removed 15 redundant
calls across 7 files.

### Phase 2: `CommandHelper.executeCommand()`
`executeCommand()` now calls `nextFrame()` internally. Removed 15
redundant calls across 7 files, including the now-redundant call in
`AppModeHelper.toggleAppMode()`.

### Phase 3: `WorkflowHelper.loadGraphData()`
New helper wraps `page.evaluate(loadGraphData)` + `nextFrame()`.
Migrated `SubgraphHelper.serializeAndReload()` and `groupNode.spec.ts`.

### Phase 4: `NodeReference` cleanup
Removed redundant `nextFrame()` from `copy()`, `convertToGroupNode()`,
`resizeNode()`, `dragTextEncodeNode2()`, and
`convertDefaultKSamplerToSubgraph()`. Removed 6 spec-level calls after
`node.click('title')`.

### Phase 5: `KeyboardHelper.press()` and `delete()`
New convenience methods that press a key and wait one frame. Converted
40 `canvas.press(key)` + `nextFrame()` pairs across 13 spec files.

### Phase 6: `ComfyPage.expectScreenshot()`
New helper combines `nextFrame()` + `toHaveScreenshot()`. Converted 45
pairs across 12 spec files.

## Total impact
- **~130 redundant `nextFrame()` calls eliminated** across ~35
spec/helper files
- **3 new helper methods** added (`loadGraphData`, `press`/`delete`,
`expectScreenshot`)
- **2 existing methods** enhanced (`setSetting`, `executeCommand`)

## What was NOT changed
- `performance.spec.ts` frame-counting loops (intentional)
- `ComfyMouse.ts` / `CanvasHelper.ts` (already internalized)
- `SubgraphHelper.packAllInteriorNodes()` (deliberate orchestration)
- Builder helpers (already internalized)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11166-refactor-internalize-nextFrame-into-fixture-helper-methods-33f6d73d3650817bb5f6fb46e396085e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-15 15:25:47 +00:00
Kelly Yang
92ad6fc798 test: address review nits for image compare E2E (#11260)
## Summary
A follow-up PR of #11196.

| # | Nit | Action | Reason |
| :--- | :--- | :--- | :--- |
| 1 | Replace `page.on('pageerror')` with request-wait | **Left as-is**
| The `pageErrors` array is an accumulator checked at the end via
`expect(pageErrors).toHaveLength(0)` – the goal is to assert that broken
image URLs don't surface as uncaught JS exceptions during the test run.
A request-wait can't substitute for that behavioral assertion, so the
listener pattern is intentional here. |
| 2 | Move helpers to a `vueNodes.getImageCompareHelper()` subclass |
**Left as-is** | Helpers such as `setImageCompareValue` and
`moveToPercentage` are only used in this file, making local
encapsulation enough. Extracting them to a page object would increase
the file/interface surface area and violate YAGNI; additionally,
`AGENTS.md` clearly states to "minimize the exported values of each
module. |
| 3 | Use `TestIds` enum for test ID strings | **Fixed** – added
`imageCompare` section to `TestIds` in `selectors.ts`; replaced all 8
inline string IDs in `imageCompare.spec.ts` with
`TestIds.imageCompare.*` references | The project already has a
`TestIds` convention for centralizing test IDs. Inline strings create
drift risk between the Vue component and the test file. |
| 4 | Move `expect.poll` bounding box check to helper/page object |
**Left as-is** | This logic already lives inside `moveToPercentage`,
which is a local helper. Moving it further to a page object is the same
refactor as #2 above. |
| 5 | Remove `// ---` style section header comments | **Fixed** –
removed all 8 divider blocks from `imageCompare.spec.ts` | Consistent
with project guidelines and your explicit preference. Test names already
describe what each block does. |
| 6 | Name magic numbers `400` and `350` | **Fixed** – introduced
`minWidth = 400` and `minHeight = 350` constants in the test |
Descriptive names make the constraint self-documenting and easier to
update if the workflow asset changes. |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E test code and shared
selector constants, with no production logic impacted.
> 
> **Overview**
> **E2E Image Compare tests now use centralized selectors.** Adds an
`imageCompare` section to `TestIds` and updates `imageCompare.spec.ts`
to reference `TestIds.imageCompare.*` instead of inline `data-testid`
strings.
> 
> Cleans up the spec by removing divider comments and naming the minimum
size magic numbers (`minWidth`, `minHeight`) used in the node sizing
assertion.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ece25be5cc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11260-test-address-review-nits-for-image-compare-E2E-3436d73d365081a69cacc1fff390035a)
by [Unito](https://www.unito.io)
2026-04-15 10:50:44 -04:00
81 changed files with 1876 additions and 1462 deletions

View File

@@ -1,167 +0,0 @@
import { expect, test } from '@playwright/test'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', 'https://app.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ page }) => {
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Unlimited',
'Any model',
'Your machine',
'Free. Open Source'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('EcoSystemSection heading is visible', async ({ page }) => {
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('ProductCardsSection links to cloud, api, enterprise', async ({
page
}) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
test('FAQSection heading is visible with 8 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(8)
})
})
test.describe('FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('all FAQs are expanded by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeVisible()
})
test('clicking an expanded FAQ collapses it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
})
})
test.describe('Download page mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('CloudBannerSection is visible', async ({ page }) => {
await expect(page.getByText(/Need more power/)).toBeVisible()
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ page }) => {
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await downloadBtn.scrollIntoViewIfNeeded()
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
expect(downloadBox, 'download button bounding box').not.toBeNull()
expect(githubBox, 'github button bounding box').not.toBeNull()
expect(githubBox!.y).toBeGreaterThan(downloadBox!.y)
})
})

View File

@@ -1,3 +0,0 @@
<svg width="62" height="94.14" viewBox="0 0 62 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.9346 0C33.456 0.000149153 39.6242 8.20368 36.7305 18.3115L33.9385 28.0635C32.7454 32.2159 35.8674 36.3555 40.1826 36.3555C42.9814 36.3555 45.4493 34.5653 46.3311 31.9268L47.7129 27.002C49.4225 20.9287 55.812 16 62 16V64H48.5342C42.3461 64 38.7182 59.0713 40.4199 52.998L40.8398 51.5L40.8301 51.4922C42.0104 47.3146 38.8756 43.1751 34.5352 43.1748C31.6287 43.1748 29.0515 45.1048 28.252 47.9111L24.3047 61.6885H24.2793C21.3855 71.7964 10.5089 80 0 80V0H22.9346Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 625 B

View File

@@ -1,32 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FAQSection from './FAQSection.vue'
const meta: Meta<typeof FAQSection> = {
title: 'Website/Common/FAQSection',
component: FAQSection,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
})
],
args: {
headingKey: 'download.faq.heading',
faqPrefix: 'download.faq',
faqCount: 3
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const ManyItems: Story = {
args: {
headingKey: 'download.faq.heading',
faqPrefix: 'download.faq',
faqCount: 8
}
}

View File

@@ -1,98 +0,0 @@
<script setup lang="ts">
import { reactive } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
locale = 'en',
headingKey,
faqPrefix,
faqCount
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
faqPrefix: string
faqCount: number
}>()
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
{ length: faqCount },
(_, i) => ({
q: `${faqPrefix}.${i + 1}.q` as TranslationKey,
a: `${faqPrefix}.${i + 1}.a` as TranslationKey
})
)
const faqs = faqKeys.map(({ q, a }) => ({
question: t(q, locale),
answer: t(a, locale)
}))
const expanded = reactive(faqs.map(() => true))
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
{{ t(headingKey, locale) }}
</h2>
</div>
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
:key="index"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:id="`faq-trigger-${index}`"
:aria-expanded="expanded[index]"
:aria-controls="`faq-panel-${index}`"
class="flex w-full cursor-pointer items-center justify-between text-left"
:class="index === 0 ? 'pb-6' : 'py-6'"
@click="toggle(index)"
>
<span
class="text-lg font-light md:text-xl"
:class="
expanded[index]
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas'
"
>
{{ faq.question }}
</span>
<span
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
aria-hidden="true"
>
{{ expanded[index] ? '' : '+' }}
</span>
</button>
<section
v-if="expanded[index]"
:id="`faq-panel-${index}`"
:aria-labelledby="`faq-trigger-${index}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,74 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import ProductCard from './ProductCard.vue'
type Product = 'local' | 'cloud' | 'api' | 'enterprise'
const {
locale = 'en',
excludeProduct,
labelKey = 'products.label'
} = defineProps<{
locale?: Locale
excludeProduct?: Product
labelKey?: TranslationKey
}>()
const routes = getRoutes(locale)
const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
cardDef('local', routes.download, 'bg-primary-warm-gray'),
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
]
function cardDef(product: Product, href: string, bg: string) {
return {
product,
title: t(`products.${product}.title`, locale),
description: t(`products.${product}.description`, locale),
cta: t(`products.${product}.cta`, locale),
href,
bg
}
}
const cards = excludeProduct
? allCards.filter((c) => c.product !== excludeProduct)
: allCards
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<!-- Header -->
<div class="flex flex-col items-center text-center">
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t(labelKey, locale) }}
</p>
<h2
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t('products.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas/70 mt-4 text-sm">
{{ t('products.subheading', locale) }}
</p>
</div>
<!-- Cards -->
<div
:class="[
'mt-16 grid grid-cols-1 gap-4',
cards.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
]"
>
<ProductCard v-for="card in cards" :key="card.product" v-bind="card" />
</div>
</section>
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
const {
logoSrc = '/icons/logo.svg',
logoAlt = 'Comfy',
text = 'LOCAL'
} = defineProps<{
logoSrc?: string
logoAlt?: string
text?: string
}>()
</script>
<template>
<div class="font-formula-condensed flex items-stretch font-semibold">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
>
<img
:src="logoSrc"
:alt="logoAlt"
class="inline-block h-6 brightness-0 lg:h-10"
/>
</span>
<img
src="/icons/node-union-2size.svg"
alt=""
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
>
<span
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
>
{{ text }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px my-auto h-7.25 self-center lg:h-15.5"
aria-hidden="true"
/>
</div>
</template>

View File

@@ -1,11 +1,67 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import ProductCardsSection from '../common/ProductCardsSection.vue'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import ProductCard from '../common/ProductCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
const cards = [
{
title: t('products.local.title', locale),
description: t('products.local.description', locale),
cta: t('products.local.cta', locale),
href: routes.download,
bg: 'bg-primary-warm-gray'
},
{
title: t('products.cloud.title', locale),
description: t('products.cloud.description', locale),
cta: t('products.cloud.cta', locale),
href: routes.cloud,
bg: 'bg-secondary-mauve'
},
{
title: t('products.api.title', locale),
description: t('products.api.description', locale),
cta: t('products.api.cta', locale),
href: routes.api,
bg: 'bg-primary-comfy-plum'
},
{
title: t('products.enterprise.title', locale),
description: t('products.enterprise.description', locale),
cta: t('products.enterprise.cta', locale),
href: routes.cloudEnterprise,
bg: 'bg-illustration-forest'
}
]
</script>
<template>
<ProductCardsSection :locale="locale" />
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<!-- Header -->
<div class="flex flex-col items-center text-center">
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t('products.label', locale) }}
</p>
<h2
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t('products.heading', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm">
{{ t('products.subheading', locale) }}
</p>
</div>
<!-- Cards -->
<div class="mt-16 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<ProductCard v-for="card in cards" :key="card.title" v-bind="card" />
</div>
</section>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="bg-transparency-white-t4 px-4 pt-28 pb-4 text-center lg:px-20 lg:pt-36 lg:pb-8"
>
<p
class="text-primary-comfy-canvas text-lg font-semibold lg:text-sm lg:font-normal"
>
{{ t('download.cloud.prefix', locale) }}
<a
:href="externalLinks.app"
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest uppercase hover:underline"
>
{{ t('download.cloud.cta', locale) }}
</a>
<span class="mt-1 block lg:mt-0 lg:inline">
{{ t('download.cloud.suffix', locale) }}
</span>
</p>
</section>
</template>

View File

@@ -1,69 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { downloadUrl } = useDownloadUrl()
</script>
<template>
<section class="px-4 py-24 lg:px-20 lg:py-40">
<div
class="flex flex-col-reverse items-stretch gap-10 lg:flex-row lg:gap-16"
>
<!-- Text content -->
<div class="flex flex-1 flex-col justify-between">
<div>
<h2 class="text-primary-comfy-canvas text-3xl font-light lg:text-4xl">
{{ t('download.ecosystem.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas/70 mt-6 text-sm">
{{ t('download.ecosystem.description', locale) }}
</p>
</div>
<!-- CTA buttons -->
<div class="mt-10 flex flex-col gap-4 lg:flex-row">
<a
:href="downloadUrl"
target="_blank"
rel="noopener"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-full px-8 py-4 text-center text-sm font-bold tracking-wider transition-opacity hover:opacity-90"
>
{{ t('download.hero.downloadLocal', locale) }}
</a>
<a
:href="externalLinks.github"
target="_blank"
rel="noopener"
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink flex items-center justify-center gap-2 rounded-full border px-8 py-4 text-sm font-bold tracking-wider transition-colors"
>
<span
class="icon-mask size-5 mask-[url('/icons/social/github.svg')]"
aria-hidden="true"
/>
{{ t('download.hero.installGithub', locale) }}
</a>
</div>
</div>
<!-- TODO: Replace with final ecosystem illustration -->
<div class="flex-1">
<div
class="aspect-4/3 w-full overflow-hidden rounded-3xl bg-linear-to-b from-emerald-300 to-amber-200"
>
<div class="flex h-full items-center justify-center">
<span
class="bg-primary-comfy-ink text-primary-comfy-yellow rounded-full px-4 py-2 text-sm font-bold"
>
4x
</span>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import FAQSection from '../../common/FAQSection.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<FAQSection
:locale="locale"
heading-key="download.faq.heading"
faq-prefix="download.faq"
:faq-count="8"
/>
</template>

View File

@@ -1,85 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import ProductHeroBadge from '../../common/ProductHeroBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { downloadUrl } = useDownloadUrl()
</script>
<template>
<section class="overflow-hidden px-4 pt-20 pb-16 lg:px-20 lg:py-24">
<div class="mx-auto flex max-w-5xl flex-col items-center">
<div class="flex w-full max-w-2xl flex-col items-center text-center">
<ProductHeroBadge />
<h1
class="text-primary-comfy-canvas mt-8 max-w-[10ch] text-5xl/tight font-light whitespace-pre-line lg:max-w-none lg:text-5xl"
>
{{ t('download.hero.heading', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-8 max-w-xs text-sm lg:mt-20 lg:max-w-xl lg:text-base"
>
{{ t('download.hero.subtitle', locale) }}
</p>
<div
class="mt-10 flex w-full max-w-md flex-col gap-4 lg:w-auto lg:max-w-none lg:flex-row"
>
<a
:href="downloadUrl"
target="_blank"
rel="noopener"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-full px-8 py-4 text-center text-sm font-bold tracking-wider transition-opacity hover:opacity-90 lg:min-w-60"
>
{{ t('download.hero.downloadLocal', locale) }}
</a>
<a
:href="externalLinks.github"
target="_blank"
rel="noopener"
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink flex items-center justify-center gap-2 rounded-full border px-8 py-4 text-sm font-bold tracking-wider transition-colors lg:min-w-60"
>
<span
class="icon-mask size-5 mask-[url('/icons/social/github.svg')]"
aria-hidden="true"
/>
{{ t('download.hero.installGithub', locale) }}
</a>
</div>
</div>
<!-- Placeholder for future animation; clipped within hero section -->
<div
class="relative mt-4 flex h-104 w-full max-w-4xl items-start justify-center overflow-hidden lg:mt-12 lg:h-136 lg:items-center"
>
<div
class="border-secondary-mauve/35 bg-primary-comfy-ink/35 absolute top-20 left-2 h-64 w-44 rotate-30 rounded-4xl border"
/>
<div
class="border-secondary-mauve/35 bg-primary-comfy-ink/35 absolute top-28 left-20 h-56 w-40 rotate-30 rounded-4xl border"
/>
<div
class="border-secondary-mauve/35 bg-primary-comfy-ink/35 absolute top-52 right-4 h-56 w-40 rotate-30 rounded-4xl border"
/>
<div class="relative z-10 mt-28 grid grid-cols-3 gap-0.5 lg:mt-0">
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-primary-comfy-plum block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-primary-comfy-plum block size-9 rounded-lg" />
<span class="bg-primary-comfy-yellow block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-primary-comfy-plum block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import ProductCardsSection from '../../common/ProductCardsSection.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<ProductCardsSection
:locale="locale"
exclude-product="local"
label-key="products.labelProducts"
/>
</template>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import SharedReasonSection from '../shared/ReasonSection.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasons = [
{
title: t('download.reason.1.title', locale),
description: t('download.reason.1.description', locale)
},
{
title: t('download.reason.2.title', locale),
description: t('download.reason.2.description', locale)
},
{
title: t('download.reason.3.title', locale),
description: t('download.reason.3.description', locale)
},
{
title: t('download.reason.4.title', locale),
description: t('download.reason.4.description', locale)
}
]
</script>
<template>
<SharedReasonSection
:heading="t('download.reason.heading', locale)"
:heading-highlight="t('download.reason.headingHighlight', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -1,62 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ReasonSection from './ReasonSection.vue'
const meta: Meta<typeof ReasonSection> = {
title: 'Website/Product/ReasonSection',
component: ReasonSection,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
})
],
args: {
heading: 'Why professionals\nchoose ',
headingHighlight: 'Comfy Local',
reasons: [
{
title: 'Unlimited\ncreative power',
description:
'Run any workflow without limits. No queues, no credits, no restrictions on what you can create.'
},
{
title: 'Any model,\nany time',
description:
'Use any open-source model instantly. Switch between Stable Diffusion, Flux, and more with a single click.'
},
{
title: 'Your machine,\nyour rules',
description:
'Your data never leaves your computer. Full privacy and complete control over your creative environment.'
},
{
title: 'Free.\nOpen Source.',
description:
'No subscriptions, no hidden fees. ComfyUI is and always will be free and open source.'
}
]
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithoutHighlight: Story = {
args: {
heading: 'Why choose Comfy',
headingHighlight: '',
reasons: [
{
title: 'Fast',
description: 'Optimized for speed and efficiency.'
},
{
title: 'Flexible',
description: 'Adapt to any workflow with ease.'
}
]
}
}

View File

@@ -1,54 +0,0 @@
<script setup lang="ts">
interface Reason {
title: string
description: string
}
const {
heading,
headingHighlight = '',
reasons
} = defineProps<{
heading: string
headingHighlight?: string
reasons: Reason[]
}>()
</script>
<template>
<section
class="flex flex-col gap-4 px-4 py-24 md:flex-row md:gap-16 md:px-20 md:py-40"
>
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-115 md:py-0"
>
<h2
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line md:text-5xl"
>
{{ heading
}}<span v-if="headingHighlight" class="text-primary-warm-white">{{
headingHighlight
}}</span>
</h2>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="(reason, i) in reasons"
:key="i"
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 md:flex-row md:gap-12"
>
<h3
class="text-primary-comfy-canvas shrink-0 text-2xl font-light whitespace-pre-line md:w-52"
>
{{ reason.title }}
</h3>
<p class="text-primary-comfy-canvas/70 flex-1 text-sm">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -1,28 +0,0 @@
import { externalLinks } from '@/config/routes'
const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
function isMobile(ua: string): boolean {
return /iphone|ipad|ipod|android/.test(ua)
}
// TODO: Only Windows x64 and macOS arm64 are available today.
// When Linux and/or macIntel builds are added, extend detection and URLs here.
function getDownloadUrl(): string {
if (typeof navigator === 'undefined') return externalLinks.github
const ua = navigator.userAgent.toLowerCase()
if (isMobile(ua)) return externalLinks.github
if (ua.includes('win')) return downloadUrls.windows
if (ua.includes('macintosh') || ua.includes('mac os x'))
return downloadUrls.macArm
return externalLinks.github
}
export function useDownloadUrl() {
return { downloadUrl: getDownloadUrl() }
}

View File

@@ -134,10 +134,6 @@ const translations = {
en: 'Comfy UI',
'zh-CN': 'Comfy UI'
},
'products.labelProducts': {
en: 'Products',
'zh-CN': '产品'
},
'products.heading': {
en: 'The AI creation\nengine for complete control',
'zh-CN': '完全掌控的\nAI 创作引擎'
@@ -222,171 +218,6 @@ const translations = {
en: "Comfy gives you the building blocks to create workflows nobody's imagined yet — and share them with everyone.",
'zh-CN': 'Comfy 为您提供构建模块,创造出前所未有的工作流——并与所有人分享。'
},
// Download FAQSection
'download.faq.heading': {
en: "FAQ's",
'zh-CN': '常见问题'
},
'download.faq.1.q': {
en: 'Do I need a GPU to run ComfyUI locally?',
'zh-CN': '本地运行 ComfyUI 需要 GPU 吗?'
},
'download.faq.1.a': {
en: 'A dedicated GPU is strongly recommended — more VRAM means bigger models and batches. No GPU? Run the same workflow on Comfy Cloud.',
'zh-CN':
'强烈建议使用独立 GPU——更大的显存意味着更大的模型和批量。没有 GPU在 Comfy Cloud 上运行相同的工作流。'
},
'download.faq.2.q': {
en: 'How much disk space do I need?',
'zh-CN': '需要多少磁盘空间?'
},
'download.faq.2.a': {
en: 'ComfyUI is lightweight, models are the heavy part. Plan for a dedicated drive as your library grows.',
'zh-CN':
'ComfyUI 本身很轻量,模型才是大头。随着库的增长,建议准备专用硬盘。'
},
'download.faq.3.q': {
en: "Is it really free? What's the catch?",
'zh-CN': '真的免费吗?有什么附加条件?'
},
'download.faq.3.a': {
en: 'Yes. Free and open source under GPL-3.0. No feature gates, no trials, no catch.',
'zh-CN':
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
},
'download.faq.4.q': {
en: 'Why would I pay for Comfy Cloud if Local is free?',
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud'
},
'download.faq.4.a': {
en: 'Your machine or ours. Cloud gives you powerful GPUs on demand, pre-loaded models, end-to-end security and infrastructure out of the box and partner models cleared for commercial use.',
'zh-CN':
'你的机器或我们的。Cloud 按需提供强大 GPU、预加载模型、端到端安全性和开箱即用的基础设施以及经过商业许可的合作伙伴模型。'
},
'download.faq.5.q': {
en: "What's the difference between Desktop, Portable, and CLI install?",
'zh-CN': 'Desktop、Portable 和 CLI 安装有什么区别?'
},
'download.faq.5.a': {
en: 'Desktop: one-click installer with auto-updates. Portable: self-contained build you can run from any folder. CLI: clone from GitHub for full developer control, for developers who want to customize the environment or contribute upstream.',
'zh-CN':
'Desktop一键安装自动更新。Portable独立构建可从任意文件夹运行。CLI从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
},
'download.faq.6.q': {
en: 'Can I use my local workflows in Comfy Cloud?',
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
},
'download.faq.6.a': {
en: 'Yes — same file, same results. No conversion, no rework.',
'zh-CN': '可以——同样的文件,同样的结果。无需转换,无需返工。'
},
'download.faq.7.q': {
en: 'How do I install custom nodes and extensions?',
'zh-CN': '如何安装自定义节点和扩展?'
},
'download.faq.7.a': {
en: 'ComfyUI Manager lets you browse, install, update, and manage 5,000+ extensions from inside the app.',
'zh-CN': 'ComfyUI Manager 让你在应用内浏览、安装、更新和管理 5,000+ 扩展。'
},
'download.faq.8.q': {
en: 'My workflow is running slowly. Should I switch to Cloud?',
'zh-CN': '我的工作流运行缓慢。应该切换到 Cloud 吗?'
},
'download.faq.8.a': {
en: 'No need to switch. Push heavy jobs to Comfy Cloud when you need more compute, keep building locally the rest of the time.',
'zh-CN':
'无需切换。需要更多算力时将繁重任务推送到 Comfy Cloud其余时间继续在本地构建。'
},
// Download EcoSystemSection
'download.ecosystem.heading': {
en: 'An ecosystem that moves faster than any company could.',
'zh-CN': '一个比任何公司都迭代更快的生态系统。'
},
'download.ecosystem.description': {
en: 'Over 5,000 community-built extensions — totaling 60,000+ nodes — plug into ComfyUI and extend what it can do. When a new open model launches, ComfyUI implements it, and the community customizes and builds it into their workflows immediately. When a research paper drops a new technique, an extension appears within days.',
'zh-CN':
'超过 5,000 个社区构建的扩展——共计 60,000+ 节点——接入 ComfyUI 并扩展其能力。当新的开源模型发布时ComfyUI 会实现它,社区会立即将其定制并构建到工作流中。当研究论文发布新技术时,几天内就会出现相应扩展。'
},
// Download ReasonSection
'download.reason.heading': {
en: 'Why\nprofessionals\nchoose ',
'zh-CN': '专业人士为何\n选择'
},
'download.reason.headingHighlight': {
en: 'Local',
'zh-CN': '本地版'
},
'download.reason.1.title': {
en: 'Unlimited\nCustomization',
'zh-CN': '无限\n自定义'
},
'download.reason.1.description': {
en: 'Install any of 5,000+ community extensions, totaling 60,000+ nodes. Build your own custom nodes. Integrate with Photoshop, Nuke, Blender, Houdini, and any tool in your existing pipeline.',
'zh-CN':
'安装 5,000+ 社区扩展中的任何一个,共计 60,000+ 节点。构建自定义节点。与 Photoshop、Nuke、Blender、Houdini 及现有管线中的任何工具集成。'
},
'download.reason.2.title': {
en: 'Any model.\nNo exceptions.',
'zh-CN': '任何模型。\n无一例外。'
},
'download.reason.2.description': {
en: 'Run every open-source model — Wan 2.1, Flux, LTX and more. Finetune, customize, control the full inference process. Or use partner models like Nano Banana and Grok.',
'zh-CN':
'运行每个开源模型——Wan 2.1、Flux、LTX 等。微调、自定义、控制完整推理过程。或使用 Nano Banana 和 Grok 等合作伙伴模型。'
},
'download.reason.3.title': {
en: 'Your machine.\nYour data.\nYour terms.',
'zh-CN': '你的机器。\n你的数据。\n你的规则。'
},
'download.reason.3.description': {
en: 'Run entirely offline. No internet connection required after setup. Your workflows, your models, your data.',
'zh-CN':
'完全离线运行。安装后无需网络连接。你的工作流、你的模型、你的数据。'
},
'download.reason.4.title': {
en: 'Free. Open Source.\nNo ceiling.',
'zh-CN': '免费。开源。\n没有上限。'
},
'download.reason.4.description': {
en: 'No feature gates, no trial periods, no "pro" tier for core functionality. No vendor can lock you in or force you off the platform. Build your own nodes and modify ComfyUI as your own.',
'zh-CN':
'没有功能限制、没有试用期、核心功能没有"专业"层级。没有供应商可以锁定你或强迫你离开平台。构建自己的节点,随心修改 ComfyUI。'
},
// Download HeroSection
'download.hero.heading': {
en: 'Run on your hardware.\nFree forever.',
'zh-CN': '在你的硬件上运行。\n永久免费。'
},
'download.hero.subtitle': {
en: 'The full ComfyUI engine — open source, fast, extensible, and yours to run however you want.',
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
},
'download.hero.downloadLocal': {
en: 'DOWNLOAD LOCAL',
'zh-CN': '下载本地版'
},
'download.hero.installGithub': {
en: 'INSTALL FROM GITHUB',
'zh-CN': '从 GITHUB 安装'
},
// Download CloudBannerSection
'download.cloud.prefix': {
en: 'Need more power?',
'zh-CN': '需要更强算力?'
},
'download.cloud.cta': {
en: 'TRY COMFY CLOUD',
'zh-CN': '试试 COMFY CLOUD'
},
'download.cloud.suffix': {
en: 'Powerful GPUs, same workflow, same results, from anywhere.',
'zh-CN': '强大 GPU同样的工作流同样的结果随时随地。'
},
'buildWhat.row1': { en: 'BUILD WHAT', 'zh-CN': '构建' },
'buildWhat.row2a': { en: "DOESN'T EXIST", 'zh-CN': '尚不存在的' },
'buildWhat.row2b': { en: 'YET', 'zh-CN': '事物' },
@@ -447,4 +278,4 @@ export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
export type { Locale, TranslationKey }
export type { Locale }

View File

@@ -1,18 +1,8 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/product/local/HeroSection.vue'
import CloudBannerSection from '../components/product/local/CloudBannerSection.vue'
import ReasonSection from '../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Download Comfy — Run AI Locally">
<CloudBannerSection />
<HeroSection client:load />
<ReasonSection />
<EcoSystemSection client:visible />
<ProductCardsSection />
<FAQSection client:visible />
<ComingSoon />
</BaseLayout>

View File

@@ -1,18 +1,76 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import CloudBannerSection from '../../components/product/local/CloudBannerSection.vue'
import HeroSection from '../../components/product/local/HeroSection.vue'
import ReasonSection from '../../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
const cards = [
{
icon: '🪟',
title: 'Windows',
description: '需要 NVIDIA 或 AMD 显卡',
cta: '下载 Windows 版',
href: 'https://download.comfy.org/windows/nsis/x64',
outlined: false,
},
{
icon: '🍎',
title: 'Mac',
description: '需要 Apple Silicon (M 系列)',
cta: '下载 Mac 版',
href: 'https://download.comfy.org/mac/dmg/arm64',
outlined: false,
},
{
icon: '🐙',
title: 'GitHub',
description: '在任何平台上从源码构建',
cta: '从 GitHub 安装',
href: 'https://github.com/comfyanonymous/ComfyUI',
outlined: true,
},
]
---
<BaseLayout title="下载 — Comfy">
<CloudBannerSection locale="zh-CN" />
<HeroSection locale="zh-CN" client:load />
<ReasonSection locale="zh-CN" />
<EcoSystemSection locale="zh-CN" client:visible />
<ProductCardsSection locale="zh-CN" />
<FAQSection locale="zh-CN" client:visible />
<div class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
下载 ComfyUI
</h1>
<p class="mt-4 text-lg text-smoke-700">
在本地体验 AI 创作
</p>
<div class="mt-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{cards.map((card) => (
<a
href={card.href}
class="flex flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
>
<span class="text-4xl" aria-hidden="true">{card.icon}</span>
<h2 class="mt-4 text-xl font-semibold text-white">{card.title}</h2>
<p class="mt-2 text-sm text-smoke-700">{card.description}</p>
<span
class:list={[
'mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90',
card.outlined
? 'border border-brand-yellow text-brand-yellow'
: 'bg-brand-yellow text-black',
]}
>
{card.cta}
</span>
</a>
))}
</div>
<div class="mt-20 rounded-xl border border-white/10 bg-charcoal-800 p-8">
<p class="text-lg text-smoke-700">
没有 GPU{' '}
<a
href="https://app.comfy.org"
class="font-semibold text-brand-yellow hover:underline"
>
试试 Comfy Cloud →
</a>
</p>
</div>
</div>
</BaseLayout>

View File

@@ -113,16 +113,3 @@
scroll-behavior: auto !important;
}
}
@utility icon-mask {
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
}
:root {
--site-bg: #211927;
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);
--site-border-subtle: rgb(255 255 255 / 0.1);
}

View File

@@ -6,6 +6,5 @@
}
},
"include": ["src/**/*", "e2e/**/*", "astro.config.ts"],
"exclude": ["src/**/*.stories.ts"],
"references": [{ "path": "./tsconfig.stories.json" }]
"exclude": ["src/**/*.stories.ts"]
}

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"composite": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.stories.ts"]
}

View File

@@ -10,7 +10,7 @@ import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { sleep } from '@e2e/fixtures/utils/timing'
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
@@ -336,9 +336,7 @@ export class ComfyPage {
}
async nextFrame() {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
await nextFrame(this.page)
}
async delay(ms: number) {
@@ -393,6 +391,27 @@ export class ComfyPage {
return this.page.locator('.dom-widget')
}
async expectScreenshot(
locator: Locator,
name: string | string[],
options?: {
animations?: 'disabled' | 'allow'
caret?: 'hide' | 'initial'
mask?: Array<Locator>
maskColor?: string
maxDiffPixelRatio?: number
maxDiffPixels?: number
omitBackground?: boolean
scale?: 'css' | 'device'
stylePath?: string | Array<string>
threshold?: number
timeout?: number
}
): Promise<void> {
await this.nextFrame()
await comfyExpect(locator).toHaveScreenshot(name, options)
}
async setFocusMode(focusMode: boolean) {
await this.page.evaluate((focusMode) => {
;(window.app!.extensionManager as WorkspaceStore).focusMode = focusMode

View File

@@ -160,6 +160,15 @@ export class AppModeHelper {
/** Enter builder mode via the "Workflow actions" dropdown. */
async enterBuilder() {
// Wait for any workflow-tab popover to dismiss before clicking —
// the popover overlay can intercept the "Workflow actions" click.
// Best-effort: the popover may or may not exist; if it stays visible
// past the timeout we still proceed with the click.
await this.page
.locator('.workflow-popover-fade')
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
@@ -174,7 +183,6 @@ export class AppModeHelper {
async toggleAppMode() {
await this.comfyPage.workflow.waitForActiveWorkflow()
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
await this.comfyPage.nextFrame()
}
/**

View File

@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position } from '@e2e/fixtures/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CanvasHelper {
constructor(
@@ -10,18 +11,12 @@ export class CanvasHelper {
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await this.nextFrame()
await nextFrame(this.page)
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
@@ -29,7 +24,7 @@ export class CanvasHelper {
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
await nextFrame(this.page)
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
@@ -38,7 +33,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
await nextFrame(this.page)
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
@@ -56,22 +51,22 @@ export class CanvasHelper {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
await nextFrame(this.page)
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
await nextFrame(this.page)
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
await nextFrame(this.page)
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await this.nextFrame()
await nextFrame(this.page)
}
/**
@@ -107,7 +102,7 @@ export class CanvasHelper {
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
await nextFrame(this.page)
}
/**
@@ -116,12 +111,12 @@ export class CanvasHelper {
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
await nextFrame(this.page)
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()
await nextFrame(this.page)
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
@@ -129,7 +124,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
await nextFrame(this.page)
}
async moveMouseToEmptyArea(): Promise<void> {
@@ -152,7 +147,7 @@ export class CanvasHelper {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await this.nextFrame()
await nextFrame(this.page)
}
async convertOffsetToCanvas(
@@ -236,12 +231,12 @@ export class CanvasHelper {
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
await nextFrame(this.page)
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
await nextFrame(this.page)
}
await this.page.mouse.up({ button: 'middle' })

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import type { KeyCombo } from '@/platform/keybindings/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CommandHelper {
constructor(private readonly page: Page) {}
@@ -20,6 +21,7 @@ export class CommandHelper {
},
{ commandId, metadata }
)
await nextFrame(this.page)
}
async registerCommand(

View File

@@ -5,18 +5,11 @@ import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class DragDropHelper {
constructor(private readonly page: Page) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
async dragAndDropExternalResource(
options: {
fileName?: string
@@ -145,7 +138,7 @@ export class DragDropHelper {
await uploadResponsePromise
}
await this.nextFrame()
await nextFrame(this.page)
}
async dragAndDropFile(

View File

@@ -1,13 +1,21 @@
import type { Locator, Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class KeyboardHelper {
constructor(
private readonly page: Page,
private readonly canvas: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
async press(key: string, locator?: Locator | null): Promise<void> {
const target = locator ?? this.canvas
await target.press(key)
await nextFrame(this.page)
}
async delete(locator?: Locator | null): Promise<void> {
await this.press('Delete', locator)
}
async ctrlSend(
@@ -16,7 +24,7 @@ export class KeyboardHelper {
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
await nextFrame(this.page)
}
async selectAll(locator?: Locator | null): Promise<void> {

View File

@@ -140,13 +140,11 @@ export class NodeOperationsHelper {
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.comfyPage.nextFrame()
if (revertAfter) {
await this.comfyPage.canvasOps.dragAndDrop(
{ x: target.x - 2, y: target.y - 1 },
bottomRight
)
await this.comfyPage.nextFrame()
}
}
@@ -158,7 +156,6 @@ export class NodeOperationsHelper {
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
}
async fillPromptDialog(value: string): Promise<void> {
@@ -192,7 +189,6 @@ export class NodeOperationsHelper {
y: 300
}
)
await this.comfyPage.nextFrame()
}
async adjustEmptyLatentWidth(): Promise<void> {

View File

@@ -1,5 +1,7 @@
import type { Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class SettingsHelper {
constructor(private readonly page: Page) {}
@@ -10,6 +12,7 @@ export class SettingsHelper {
},
{ id: settingId, value: settingValue }
)
await nextFrame(this.page)
}
async getSetting<T = unknown>(settingId: string): Promise<T> {

View File

@@ -465,11 +465,7 @@ export class SubgraphHelper {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
await this.comfyPage.workflow.loadGraphData(serialized as ComfyWorkflowJSON)
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
@@ -477,14 +473,12 @@ export class SubgraphHelper {
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,

View File

@@ -70,10 +70,19 @@ export class WorkflowHelper {
)
}
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
await this.comfyPage.page.evaluate(
(wf) => window.app!.loadGraphData(wf),
workflow
)
await this.comfyPage.nextFrame()
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
)
await this.waitForWorkflowIdle()
await this.comfyPage.nextFrame()
if (test.info().tags.includes('@vue-nodes')) {
await this.comfyPage.vueNodes.waitForNodes()

View File

@@ -198,6 +198,16 @@ export const TestIds = {
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
batchNav: 'batch-nav',
beforeBatch: 'before-batch',
afterBatch: 'after-batch',
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
}
} as const
@@ -231,3 +241,4 @@ export type TestIdValue =
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]

View File

@@ -388,7 +388,6 @@ export class NodeReference {
async copy() {
await this.click('title')
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
@@ -434,7 +433,6 @@ export class NodeReference {
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)

View File

@@ -1,3 +1,9 @@
import type { Page } from '@playwright/test'
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function nextFrame(page: Page): Promise<number> {
return page.evaluate(() => new Promise<number>(requestAnimationFrame))
}

View File

@@ -157,7 +157,10 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
test('Reordering inputs in one app does not corrupt another app', async ({
comfyPage
}) => {
}, testInfo) => {
// This test creates 2 apps, switches tabs 3 times, and enters builder 3
// times — the default 15s timeout is insufficient in CI.
testInfo.setTimeout(45_000)
const { appMode } = comfyPage
const app2Widgets = ['seed', 'steps']
const app1Reordered = ['steps', 'cfg', 'seed']

View File

@@ -110,8 +110,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})

View File

@@ -29,7 +29,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
await comfyPage.nextFrame()
})
test.describe('Trigger button', () => {
@@ -46,7 +45,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(mode.iconPattern)
@@ -103,7 +101,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
}) => {
if (!mode.isReadOnly) {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
}
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
@@ -156,7 +153,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -208,7 +204,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -229,8 +224,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts unlocked'
).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyH')
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
@@ -241,13 +235,11 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts locked'
).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyV')
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()

View File

@@ -223,8 +223,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(node).toBePinned()
await afterChange(comfyPage)
}

View File

@@ -74,18 +74,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
return node!.id
})
// Wait for the asset widget to mount AND its value to resolve.
// The widget type becomes 'asset' before the value is populated,
// so poll for both conditions together to avoid a race where the
// type check passes but the value is still the placeholder.
await expect
.poll(
async () => {
return await comfyPage.page.evaluate((id) => {
() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
}, nodeId)
},
{ timeout: 10_000 }
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)
})

View File

@@ -157,18 +157,15 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-light-red.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
@@ -181,7 +178,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -190,7 +186,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
'Comfy.ColorPalette',
'custom_obsidian_dark'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -212,15 +207,12 @@ test.describe(
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
@@ -229,8 +221,8 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'node-opacity-0.2-arc-theme.png'
)
})
@@ -240,7 +232,6 @@ test.describe(
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
@@ -279,7 +270,6 @@ test.describe(
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-lightened-colors.png'
)

View File

@@ -155,7 +155,6 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
await comfyPage.nextFrame()
const uploadPromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,

View File

@@ -52,8 +52,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+Equal')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -63,8 +62,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+Minus')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -82,8 +80,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Period')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -93,8 +90,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'h' locks canvas", async ({ comfyPage }) => {
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyH')
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
})
@@ -102,11 +98,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyV')
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
})
@@ -121,16 +115,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
await expect.poll(() => node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+KeyC')
await expect.poll(() => node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+KeyC')
await expect.poll(() => node.isCollapsed()).toBe(false)
})
@@ -140,7 +131,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
@@ -150,13 +140,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect.poll(() => getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+KeyM')
// NEVER (2) = muted
await expect.poll(() => getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+KeyM')
await expect.poll(() => getMode()).toBe(0)
})
})
@@ -239,16 +227,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+s')
// 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.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
@@ -265,8 +251,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+o')
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
@@ -288,11 +273,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Shift+KeyE')
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)

View File

@@ -145,15 +145,27 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may re-render during search
// filtering, causing the first click to land on a stale element.
// Wait for the search filter to fully settle — PrimeVue re-renders
// the entire settings list after typing, and the combobox element is
// replaced during re-render. Wait until the filtered list stabilises
// before interacting with the combobox.
const settingItems = dialog.root.locator('[data-setting-id]')
await expect
.poll(() => settingItems.count(), { timeout: 5000 })
.toBeLessThanOrEqual(5)
const select = settingRow.getByRole('combobox')
await expect(select).toBeVisible()
await expect(select).toBeEnabled()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may still re-render after the
// filter settles, causing the first click to land on a stale element.
await expect(async () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 5000 })
}).toPass({ timeout: 10_000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'

View File

@@ -24,8 +24,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
TestIds.canvas.toggleLinkVisibilityButton
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
@@ -36,8 +36,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
.toBe(hiddenLinkRenderMode)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'canvas-with-visible-links.png'
)
await expect

View File

@@ -170,7 +170,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
@@ -336,12 +335,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) =>
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
currentGraphState
await comfyPage.workflow.loadGraphData(
currentGraphState as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
await verifyNodeLoaded(comfyPage, 1)
})
})

View File

@@ -60,7 +60,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -84,7 +83,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -107,7 +105,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')

View File

@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -87,10 +88,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
test(
'Shows empty state when no images are set',
{ tag: '@smoke' },
@@ -98,7 +95,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByTestId('image-compare-empty')).toBeVisible()
await expect(node.getByTestId(TestIds.imageCompare.empty)).toBeVisible()
await expect(node.locator('img')).toHaveCount(0)
await expect(node.getByRole('presentation')).toHaveCount(0)
}
@@ -126,10 +123,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
@@ -164,10 +157,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider interaction
// ---------------------------------------------------------------------------
test(
'Mouse hover moves slider position',
{ tag: '@smoke' },
@@ -183,7 +172,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const handle = node.getByRole('presentation')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -224,7 +213,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -261,7 +250,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const compareArea = node.getByTestId('image-compare-viewport')
const compareArea = node.getByTestId(TestIds.imageCompare.viewport)
await expect(compareArea).toBeVisible()
await expect
@@ -292,10 +281,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBeCloseTo(100, 0)
})
// ---------------------------------------------------------------------------
// Single image modes
// ---------------------------------------------------------------------------
test('Only before image shows without slider when afterImages is empty', async ({
comfyPage
}) => {
@@ -324,10 +309,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(node.getByRole('presentation')).toBeHidden()
})
// ---------------------------------------------------------------------------
// Batch navigation
// ---------------------------------------------------------------------------
test(
'Batch navigation appears when before side has multiple images',
{ tag: '@smoke' },
@@ -342,13 +323,21 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
await expect(node.getByTestId('batch-nav')).toBeVisible()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
await expect(
node.getByTestId(TestIds.imageCompare.batchNav)
).toBeVisible()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 3')
// after-batch renders only when afterBatchCount > 1
await expect(node.getByTestId('after-batch')).toBeHidden()
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
await expect(
node.getByTestId(TestIds.imageCompare.afterBatch)
).toBeHidden()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeDisabled()
}
)
@@ -362,7 +351,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.getByTestId('batch-nav')).toBeHidden()
await expect(node.getByTestId(TestIds.imageCompare.batchNav)).toBeHidden()
})
test(
@@ -378,10 +367,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
await nextBtn.click()
await expect(counter).toHaveText('2 / 3')
@@ -407,10 +396,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
await nextBtn.click()
await nextBtn.click()
@@ -436,14 +425,18 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
await beforeBatch.getByTestId('batch-next').click()
await afterBatch.getByTestId('batch-next').click()
await beforeBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await afterBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 3')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
@@ -454,11 +447,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
})
// ---------------------------------------------------------------------------
// Node sizing
// ---------------------------------------------------------------------------
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
const minWidth = 400
const minHeight = 350
const size = await comfyPage.page.evaluate(() => {
const graphNode = window.app!.graph.getNodeById(1)
if (!graphNode?.size) return null
@@ -472,17 +463,13 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(
size.width,
'ImageCompare node minimum width'
).toBeGreaterThanOrEqual(400)
).toBeGreaterThanOrEqual(minWidth)
expect(
size.height,
'ImageCompare node minimum height'
).toBeGreaterThanOrEqual(350)
).toBeGreaterThanOrEqual(minHeight)
})
// ---------------------------------------------------------------------------
// Visual regression screenshots
// ---------------------------------------------------------------------------
for (const { pct, expectedClipMin, expectedClipMax } of [
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
@@ -500,7 +487,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await waitForImagesLoaded(node)
await expect(viewport).toBeVisible()
await moveToPercentage(comfyPage.page, viewport, pct)
@@ -516,10 +503,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
test('Widget handles image load failure gracefully', async ({
comfyPage
}) => {
@@ -586,9 +569,14 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByTestId('before-batch').getByTestId('batch-next').click()
await node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchNext)
.click()
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await setImageCompareValue(comfyPage, {
@@ -601,7 +589,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
green1Url
)
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 2')
})
@@ -656,23 +646,35 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
const beforeNext = beforeBatch.getByTestId('batch-next')
const afterNext = afterBatch.getByTestId('batch-next')
const beforeNext = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const afterNext = afterBatch.getByTestId(TestIds.imageCompare.batchNext)
for (let i = 0; i < 19; i++) {
await beforeNext.click()
await afterNext.click()
}
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(beforeBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(afterBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(beforeNext).toBeDisabled()
await expect(afterNext).toBeDisabled()
})

View File

@@ -31,11 +31,9 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
})
})
@@ -76,13 +74,11 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode2
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png')
}
)
@@ -174,8 +170,7 @@ test.describe('Node Interaction', () => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
@@ -185,7 +180,6 @@ test.describe('Node Interaction', () => {
// Pin this suite to the legacy canvas path so Alt+drag exercises
// LGraphCanvas, not the Vue node drag handler.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
})
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
@@ -285,7 +279,6 @@ test.describe('Node Interaction', () => {
}) => {
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
@@ -359,8 +352,8 @@ test.describe('Node Interaction', () => {
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'batch-disconnect-links-disconnected.png'
)
}
@@ -410,8 +403,8 @@ test.describe('Node Interaction', () => {
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
// Move mouse away to avoid hover highlight differences.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'text-encode-toggled-back-open.png'
)
}
@@ -514,8 +507,7 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot(
'group-selected-nodes.png'
)
@@ -1171,8 +1163,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-middle-drag-pan.png'
)
})
@@ -1180,14 +1172,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-wheel-zoom-in.png'
)
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-wheel-zoom-out.png'
)
})
@@ -1247,8 +1239,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-middle-drag-pan.png'
)
})
@@ -1258,16 +1250,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-ctrl-wheel-zoom-in.png'
)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-ctrl-wheel-zoom-out.png'
)
})
@@ -1359,33 +1351,31 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-right.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, -240)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-left.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-center.png'
)
})

View File

@@ -112,9 +112,8 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
await comfyPage.expectScreenshot(
load3d.node,
'load3d-uploaded-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)
@@ -142,9 +141,8 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
await comfyPage.expectScreenshot(
load3d.node,
'load3d-dropped-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)

View File

@@ -143,8 +143,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
}
)

View File

@@ -11,8 +11,10 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'mobile-empty-canvas.png'
)
})
test('@mobile default workflow', async ({ comfyPage }) => {
@@ -24,7 +26,6 @@ test.describe(
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.nextFrame()
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
@@ -38,9 +39,8 @@ test.describe(
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.settingDialog.root,
'mobile-settings-dialog.png',
{
mask: [

View File

@@ -13,7 +13,6 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
@@ -35,7 +34,6 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -14,7 +14,6 @@ async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -53,7 +52,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -110,8 +108,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -124,8 +121,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Delete')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -138,8 +134,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Backspace')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()

View File

@@ -303,8 +303,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'link-release-context-menu.png'
)
}

View File

@@ -19,8 +19,10 @@ test.describe(
await comfyPage.page.getByText('loaders').click()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-node-node-added.png'
)
})
test('Can add group', async ({ comfyPage }) => {
@@ -28,8 +30,8 @@ test.describe(
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-group-group-added.png'
)
})
@@ -45,8 +47,8 @@ test.describe(
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
@@ -60,12 +62,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-properties-panel.png'
)
})
@@ -76,12 +77,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-collapsed.png'
)
})
@@ -104,8 +104,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-collapsed-badge.png'
)
})
@@ -116,12 +116,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-bypassed.png'
)
})
@@ -133,8 +132,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -149,8 +147,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-pinned-node.png'
)
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
@@ -160,8 +158,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-unpinned-node.png'
)
})
@@ -206,8 +204,10 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-pinned.png'
)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'
@@ -216,8 +216,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-unpinned.png'
)
})

View File

@@ -11,15 +11,13 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
// Use canvas press for keyboard shortcuts (doesn't need click target)
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect(comfyPage.vueNodes.selectedNodes).not.toHaveCount(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
@@ -70,8 +68,7 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())

View File

@@ -267,8 +267,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
.click()
// Undo the colorization
await comfyPage.page.keyboard.press('Control+Z')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Z')
// Node should be uncolored again
const selectedNode = (

View File

@@ -32,7 +32,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
@@ -69,7 +68,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -83,7 +81,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -160,7 +157,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -187,7 +183,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
@@ -229,7 +224,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (

View File

@@ -14,7 +14,6 @@ test.describe(
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
@@ -43,7 +42,6 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -39,7 +39,6 @@ test.describe('Sidebar splitter width independence', () => {
location: 'left' | 'right'
) {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
await comfyPage.nextFrame()
await dismissToasts(comfyPage)
await comfyPage.menu.nodeLibraryTab.open()
}

View File

@@ -14,7 +14,6 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea

View File

@@ -37,7 +37,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
@@ -49,8 +48,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await comfyPage.canvas.dblclick({
position: {
@@ -64,8 +62,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Enter')
await subgraphNode.navigateIntoSubgraph()
await expect(breadcrumb).toBeVisible()
@@ -78,7 +75,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
@@ -90,13 +86,11 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
@@ -106,7 +100,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.describe('Navigation Hotkeys', () => {
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
@@ -135,7 +128,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -145,8 +137,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
@@ -154,8 +145,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
.toBe(true)
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+q')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -163,7 +153,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -183,8 +172,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
@@ -192,8 +180,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
@@ -205,7 +192,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
@@ -272,7 +258,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -296,8 +281,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect
.poll(() =>
@@ -312,7 +296,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -328,10 +311,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect
.poll(() =>

View File

@@ -18,7 +18,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

View File

@@ -38,13 +38,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+c')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+v')
await expect
.poll(() => comfyPage.subgraph.getNodeCount())

View File

@@ -102,7 +102,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -150,7 +149,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
@@ -318,7 +316,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
@@ -403,7 +400,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
@@ -455,7 +451,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
await expect
@@ -476,7 +471,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')

View File

@@ -68,7 +68,6 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
@@ -88,8 +87,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()

View File

@@ -15,8 +15,7 @@ async function exitSubgraphAndPublish(
subgraphNode: Awaited<ReturnType<typeof createSubgraphAndNavigateInto>>,
blueprintName: string
) {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {

View File

@@ -40,7 +40,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
@@ -59,7 +58,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
await expect
.poll(async () => {
@@ -73,20 +71,17 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -121,7 +116,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,

View File

@@ -424,7 +424,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()

View File

@@ -34,9 +34,8 @@ test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
{ message: 'All nodes should be within the visible viewport' }
)
.toBe(true)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'viewport-fits-when-saved-offscreen.png'
)
})

View File

@@ -121,8 +121,8 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
})
@@ -131,7 +131,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await comfyPage.keyboard.selectAll()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-fit-to-contents.png'
)

View File

@@ -24,8 +24,8 @@ test.describe('Vue Node Bypass', { tag: '@vue-nodes' }, () => {
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-node-bypassed-state.png'
)

View File

@@ -5,17 +5,19 @@ import {
const MUTE_HOTKEY = 'Control+m'
const MUTE_OPACITY = '0.5'
const SELECTED_CLASS = /outline-node-component-outline/
test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
test(
'should allow toggling mute on a selected node with hotkey',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await comfyPage.page.getByText('Load Checkpoint').click()
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-muted-state.png'
@@ -29,12 +31,14 @@ test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.getByText('Load Checkpoint').click()
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await expect(ksamplerNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)

View File

@@ -1,7 +1,4 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -13,8 +10,8 @@ test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
'reroute node visual appearance',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-reroute-node-compact.png'
)
}

View File

@@ -149,7 +149,6 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(1)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
@@ -289,10 +288,8 @@ test.describe('Workflow Persistence', () => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
@@ -349,7 +346,6 @@ test.describe('Workflow Persistence', () => {
// Create B: duplicate, add a node, then save (unmodified after save)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
@@ -410,7 +406,6 @@ test.describe('Workflow Persistence', () => {
// Create B: duplicate and save
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
// Add a Note node in B to mark it as modified
@@ -487,7 +482,6 @@ test.describe('Workflow Persistence', () => {
// Create B as an unsaved workflow with a Note node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))

File diff suppressed because it is too large Load Diff

View File

@@ -264,7 +264,7 @@ function normalizeSavedSizes() {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}${focusMode.value ? '-focus' : ''}`
})
const firstPanelStyle = computed(() => {

View File

@@ -0,0 +1,446 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { api } from '@/scripts/api'
import { usePainter } from './usePainter'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
}))
vi.mock('@vueuse/core', () => ({
useElementSize: vi.fn(() => ({
width: ref(512),
height: ref(512)
}))
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/updates/common/toastStore', () => {
const store = { addAlert: vi.fn() }
return { useToastStore: () => store }
})
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
fetchApi: vi.fn()
}
}))
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn(() => null)
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: {
getNodeById: vi.fn(() => ({
get widgets() {
return mockWidgets
},
get properties() {
return mockProperties
},
isInputConnected: mockIsInputConnected,
getInputNode: mockGetInputNode
}))
}
}
}
}))
type PainterResult = ReturnType<typeof usePainter>
function makeWidget(name: string, value: unknown = null): IBaseWidget {
return {
name,
value,
callback: vi.fn(),
serializeValue: undefined
} as unknown as IBaseWidget
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
function mountPainter(nodeId = 'test-node', initialModelValue = '') {
let painter!: PainterResult
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursorEl = ref<HTMLElement | null>(null)
const modelValue = ref(initialModelValue)
const Wrapper = defineComponent({
setup() {
painter = usePainter(nodeId, {
canvasEl,
cursorEl,
modelValue
})
return {}
},
render() {
return null
}
})
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
}
describe('usePainter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
mockWidgets.length = 0
for (const key of Object.keys(mockProperties)) {
delete mockProperties[key]
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
})
describe('syncCanvasSizeFromWidgets', () => {
it('reads width/height from widget values on initialization', () => {
mockWidgets.push(makeWidget('width', 1024), makeWidget('height', 768))
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(1024)
expect(painter.canvasHeight.value).toBe(768)
})
it('defaults to 512 when widgets are missing', () => {
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
})
describe('restoreSettingsFromProperties', () => {
it('restores tool and brush settings from node properties on init', () => {
mockProperties.painterTool = 'eraser'
mockProperties.painterBrushSize = 42
mockProperties.painterBrushColor = '#ff0000'
mockProperties.painterBrushOpacity = 0.5
mockProperties.painterBrushHardness = 0.8
const { painter } = mountPainter()
expect(painter.tool.value).toBe('eraser')
expect(painter.brushSize.value).toBe(42)
expect(painter.brushColor.value).toBe('#ff0000')
expect(painter.brushOpacity.value).toBe(0.5)
expect(painter.brushHardness.value).toBe(0.8)
})
it('restores backgroundColor from bg_color widget', () => {
mockWidgets.push(makeWidget('bg_color', '#123456'))
const { painter } = mountPainter()
expect(painter.backgroundColor.value).toBe('#123456')
})
it('keeps defaults when no properties are stored', () => {
const { painter } = mountPainter()
expect(painter.tool.value).toBe('brush')
expect(painter.brushSize.value).toBe(20)
expect(painter.brushColor.value).toBe('#ffffff')
expect(painter.brushOpacity.value).toBe(1)
expect(painter.brushHardness.value).toBe(1)
})
})
describe('saveSettingsToProperties', () => {
it('persists tool settings to node properties when they change', async () => {
const { painter } = mountPainter()
painter.tool.value = 'eraser'
painter.brushSize.value = 50
painter.brushColor.value = '#00ff00'
painter.brushOpacity.value = 0.7
painter.brushHardness.value = 0.3
await nextTick()
expect(mockProperties.painterTool).toBe('eraser')
expect(mockProperties.painterBrushSize).toBe(50)
expect(mockProperties.painterBrushColor).toBe('#00ff00')
expect(mockProperties.painterBrushOpacity).toBe(0.7)
expect(mockProperties.painterBrushHardness).toBe(0.3)
})
})
describe('syncCanvasSizeToWidgets', () => {
it('syncs canvas dimensions to widgets when size changes', async () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
painter.canvasWidth.value = 800
painter.canvasHeight.value = 600
await nextTick()
expect(widthWidget.value).toBe(800)
expect(heightWidget.value).toBe(600)
expect(widthWidget.callback).toHaveBeenCalledWith(800)
expect(heightWidget.callback).toHaveBeenCalledWith(600)
})
})
describe('syncBackgroundColorToWidget', () => {
it('syncs background color to widget when color changes', async () => {
const bgWidget = makeWidget('bg_color', '#000000')
mockWidgets.push(bgWidget)
const { painter } = mountPainter()
painter.backgroundColor.value = '#ff00ff'
await nextTick()
expect(bgWidget.value).toBe('#ff00ff')
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
})
})
describe('updateInputImageUrl', () => {
it('sets isImageInputConnected to false when input is not connected', () => {
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(false)
expect(painter.inputImageUrl.value).toBeNull()
})
it('sets isImageInputConnected to true when input is connected', () => {
mockIsInputConnected.mockReturnValue(true)
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(true)
})
})
describe('handleInputImageLoad', () => {
it('updates canvas size and widgets from loaded image dimensions', () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
const fakeEvent = {
target: {
naturalWidth: 1920,
naturalHeight: 1080
}
} as unknown as Event
painter.handleInputImageLoad(fakeEvent)
expect(painter.canvasWidth.value).toBe(1920)
expect(painter.canvasHeight.value).toBe(1080)
expect(widthWidget.value).toBe(1920)
expect(heightWidget.value).toBe(1080)
})
})
describe('cursor visibility', () => {
it('sets cursorVisible to true on pointer enter', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
expect(painter.cursorVisible.value).toBe(true)
})
it('sets cursorVisible to false on pointer leave', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
painter.handlePointerLeave()
expect(painter.cursorVisible.value).toBe(false)
})
})
describe('displayBrushSize', () => {
it('scales brush size by canvas display ratio', () => {
const { painter } = mountPainter()
// canvasDisplayWidth=512, canvasWidth=512 → ratio=1
// hardness=1 → effectiveRadius = radius * 1.0
// displayBrushSize = (20/2) * 1.0 * 2 * 1 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('increases for soft brush hardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0
// hardness=0 → effectiveRadius = 10 * 1.5 = 15
// displayBrushSize = 15 * 2 * 1 = 30
expect(painter.displayBrushSize.value).toBe(30)
})
})
describe('activeHardness (via displayBrushSize)', () => {
it('returns 1 for eraser regardless of brushHardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0.3
painter.tool.value = 'eraser'
// eraser hardness=1 → displayBrushSize = 10 * 1.0 * 2 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('uses brushHardness for brush tool', () => {
const { painter } = mountPainter()
painter.tool.value = 'brush'
painter.brushHardness.value = 0.5
// hardness=0.5 → scale=1.25 → 10*1.25*2 = 25
expect(painter.displayBrushSize.value).toBe(25)
})
})
describe('registerWidgetSerialization', () => {
it('attaches serializeValue to the mask widget on init', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
expect(maskWidget.serializeValue).toBeTypeOf('function')
})
})
describe('serializeValue', () => {
it('returns empty string when canvas has no strokes', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
})
it('returns existing modelValue when not dirty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
// isCanvasEmpty() is true (no strokes drawn), so returns ''
expect(result).toBe('')
})
})
describe('restoreCanvas', () => {
it('builds correct URL from modelValue on mount', () => {
const { modelValue } = mountPainter()
// Before mount, set the modelValue
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
// With empty modelValue, restoreCanvas exits early
expect(modelValue.value).toBe('')
})
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter('test-node', 'painter/my-image.png [temp]')
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('filename=my-image.png')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('subfolder=painter')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('type=temp')
)
})
})
describe('handleClear', () => {
it('does not throw when canvas element is null', () => {
const { painter } = mountPainter()
expect(() => painter.handleClear()).not.toThrow()
})
})
describe('handlePointerDown', () => {
it('ignores non-primary button clicks', () => {
const { painter } = mountPainter()
const mockSetPointerCapture = vi.fn()
const event = new PointerEvent('pointerdown', {
button: 2
})
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: mockSetPointerCapture
}
})
painter.handlePointerDown(event)
expect(mockSetPointerCapture).not.toHaveBeenCalled()
})
})
describe('handlePointerUp', () => {
it('ignores non-primary button releases', () => {
const { painter } = mountPainter()
const mockReleasePointerCapture = vi.fn()
const event = {
button: 2,
target: {
releasePointerCapture: mockReleasePointerCapture
}
} as unknown as PointerEvent
painter.handlePointerUp(event)
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { getWebpMetadata } from './pnginfo'
function buildExifPayload(workflowJson: string): Uint8Array {
const fullStr = `workflow:${workflowJson}\0`
const strBytes = new TextEncoder().encode(fullStr)
const headerSize = 22
const buf = new Uint8Array(headerSize + strBytes.length)
const dv = new DataView(buf.buffer)
buf.set([0x49, 0x49], 0)
dv.setUint16(2, 0x002a, true)
dv.setUint32(4, 8, true)
dv.setUint16(8, 1, true)
dv.setUint16(10, 0, true)
dv.setUint16(12, 2, true)
dv.setUint32(14, strBytes.length, true)
dv.setUint32(18, 22, true)
buf.set(strBytes, 22)
return buf
}
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
const exifPayload = buildExifPayload(workflowJson)
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
const buffer = new Uint8Array(totalSize)
const dv = new DataView(buffer.buffer)
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
dv.setUint32(4, totalSize - 8, true)
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
dv.setUint32(16, precedingChunkLength, true)
const exifStart = 20 + precedingPadded
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
dv.setUint32(exifStart + 4, exifPayload.length, true)
buffer.set(exifPayload, exifStart + 8)
return new File([buffer], 'test.webp', { type: 'image/webp' })
}
describe('getWebpMetadata', () => {
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
const workflow = '{"nodes":[]}'
const file = buildWebp(3, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
it('finds workflow when preceding chunk has even length (no padding)', async () => {
const workflow = '{"nodes":[1]}'
const file = buildWebp(4, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import type { SystemStats } from '@/schemas/apiSchema'
import type { ErrorReportData } from './errorReportUtil'
import { generateErrorReport } from './errorReportUtil'
const baseSystemStats: SystemStats = {
system: {
os: 'linux',
comfyui_version: '1.0.0',
python_version: '3.11',
pytorch_version: '2.0',
embedded_python: false,
argv: ['main.py'],
ram_total: 0,
ram_free: 0
},
devices: []
}
const baseWorkflow = { nodes: [], links: [] } as unknown as ISerialisedGraph
function buildError(serverLogs: unknown): ErrorReportData {
return {
exceptionType: 'RuntimeError',
exceptionMessage: 'boom',
systemStats: baseSystemStats,
serverLogs: serverLogs as string,
workflow: baseWorkflow
}
}
describe('generateErrorReport', () => {
it('embeds string serverLogs verbatim', () => {
const report = generateErrorReport(buildError('line one\nline two'))
expect(report).toContain('line one\nline two')
expect(report).not.toContain('[object Object]')
})
it('stringifies object serverLogs instead of rendering [object Object]', () => {
const report = generateErrorReport(
buildError({ entries: [{ msg: 'hello' }] })
)
expect(report).not.toContain('[object Object]')
expect(report).toContain('"entries"')
expect(report).toContain('"msg": "hello"')
})
})