mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 13:59:28 +00:00
Compare commits
8 Commits
feat/websi
...
fix/focus-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076a6d95f1 | ||
|
|
a8e1fa8bef | ||
|
|
83ceef8cb3 | ||
|
|
4885ef856c | ||
|
|
873a75d607 | ||
|
|
ecb6fbe8fb | ||
|
|
52ccd9ed1a | ||
|
|
92ad6fc798 |
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "e2e/**/*", "astro.config.ts"],
|
||||
"exclude": ["src/**/*.stories.ts"],
|
||||
"references": [{ "path": "./tsconfig.stories.json" }]
|
||||
"exclude": ["src/**/*.stories.ts"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, {}))
|
||||
|
||||
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
858
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -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(() => {
|
||||
|
||||
446
src/composables/painter/usePainter.test.ts
Normal file
446
src/composables/painter/usePainter.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
67
src/scripts/pnginfo.test.ts
Normal file
67
src/scripts/pnginfo.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
52
src/utils/errorReportUtil.test.ts
Normal file
52
src/utils/errorReportUtil.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user