mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-23 15:59:47 +00:00
Compare commits
11 Commits
bl/job-his
...
feat/websi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffe1b4ce9d | ||
|
|
0fa90b0177 | ||
|
|
2b4afd132f | ||
|
|
0b35b7acfc | ||
|
|
42cc892869 | ||
|
|
b25d8c23c8 | ||
|
|
e10dfb98eb | ||
|
|
97c50a30a7 | ||
|
|
10f0602b20 | ||
|
|
c7833ca5f1 | ||
|
|
47293e1203 |
167
apps/website/e2e/download.spec.ts
Normal file
167
apps/website/e2e/download.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
3
apps/website/public/icons/node-union-2size.svg
Normal file
3
apps/website/public/icons/node-union-2size.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 625 B |
32
apps/website/src/components/common/FAQSection.stories.ts
Normal file
32
apps/website/src/components/common/FAQSection.stories.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
98
apps/website/src/components/common/FAQSection.vue
Normal file
98
apps/website/src/components/common/FAQSection.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<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>
|
||||
74
apps/website/src/components/common/ProductCardsSection.vue
Normal file
74
apps/website/src/components/common/ProductCardsSection.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<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>
|
||||
56
apps/website/src/components/common/ProductHeroBadge.vue
Normal file
56
apps/website/src/components/common/ProductHeroBadge.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<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>
|
||||
@@ -14,9 +14,12 @@ useFrameScrub(canvasRef, {
|
||||
frameSrc: (i) =>
|
||||
`/videos/hero-logo-seq/Logo${String(i).padStart(2, '0')}.webp`,
|
||||
scrollTrigger: (canvas) => ({
|
||||
trigger: canvas,
|
||||
start: 'top 80%',
|
||||
end: 'bottom 20%',
|
||||
trigger: document.documentElement,
|
||||
start: 'top top',
|
||||
end: () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return `+=${rect.bottom + window.scrollY}`
|
||||
},
|
||||
scrub: 0.3
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,67 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import ProductCard from '../common/ProductCard.vue'
|
||||
import ProductCardsSection from '../common/ProductCardsSection.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>
|
||||
<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>
|
||||
<ProductCardsSection :locale="locale" />
|
||||
</template>
|
||||
|
||||
@@ -29,13 +29,14 @@ const { activeIndex: activeCategory } = usePinScrub(
|
||||
{ itemCount: categories.length }
|
||||
)
|
||||
|
||||
useParallax([leftImgRef, rightImgRef], { trigger: sectionRef })
|
||||
useParallax([rightImgRef], { trigger: sectionRef })
|
||||
useParallax([leftImgRef], { trigger: sectionRef, y: -60 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="bg-primary-comfy-ink relative flex flex-col items-center overflow-hidden px-8 py-20 lg:h-screen lg:px-0 lg:py-24"
|
||||
class="bg-primary-comfy-ink relative flex flex-col items-center overflow-hidden px-8 py-20 lg:h-[calc(100vh+60px)] lg:px-0 lg:py-24"
|
||||
>
|
||||
<!-- Left image -->
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { t } from '../../../i18n/translations'
|
||||
import FeatureShowcaseSection from '../shared/FeatureShowcaseSection.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: t('api.automation.feature1.title', locale),
|
||||
description: t('api.automation.feature1.description', locale),
|
||||
description2: t('api.automation.feature1.description2', locale)
|
||||
},
|
||||
{
|
||||
title: t('api.automation.feature2.title', locale),
|
||||
description: t('api.automation.feature2.description', locale),
|
||||
description2: t('api.automation.feature2.description2', locale)
|
||||
},
|
||||
{
|
||||
title: t('api.automation.feature3.title', locale),
|
||||
description: t('api.automation.feature3.description', locale)
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureShowcaseSection
|
||||
:heading="t('api.automation.heading', locale)"
|
||||
:subtitle="t('api.automation.subtitle', locale)"
|
||||
:features="features"
|
||||
/>
|
||||
</template>
|
||||
124
apps/website/src/components/product/api/HeroSection.vue
Normal file
124
apps/website/src/components/product/api/HeroSection.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import ProductHeroBadge from '../../common/ProductHeroBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</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 text="API" />
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-8 text-5xl/tight font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t('api.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('api.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="externalLinks.app"
|
||||
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('api.hero.getApiKeys', locale) }}
|
||||
</a>
|
||||
<a
|
||||
:href="externalLinks.docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink rounded-full border px-8 py-4 text-center text-sm font-bold tracking-wider transition-colors lg:min-w-60"
|
||||
>
|
||||
{{ t('api.hero.viewDocs', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Isometric node illustration -->
|
||||
<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"
|
||||
>
|
||||
<!-- Background layers -->
|
||||
<div
|
||||
class="border-secondary-mauve/20 absolute bottom-8 h-40 w-72 rounded-3xl border lg:bottom-16 lg:h-48 lg:w-96"
|
||||
/>
|
||||
<div
|
||||
class="border-secondary-mauve/20 absolute bottom-16 h-40 w-60 rounded-3xl border lg:bottom-28 lg:h-48 lg:w-80"
|
||||
/>
|
||||
<div
|
||||
class="border-secondary-mauve/20 absolute bottom-24 h-40 w-48 rounded-3xl border lg:bottom-40 lg:h-48 lg:w-64"
|
||||
/>
|
||||
|
||||
<!-- Isometric grid of nodes -->
|
||||
<div
|
||||
class="relative z-10 mt-28 grid grid-cols-5 gap-1 lg:mt-0 lg:gap-1.5"
|
||||
style="transform: rotateX(55deg) rotateZ(45deg)"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span class="bg-secondary-mauve block size-8 rounded-lg lg:size-10" />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-8 rounded-lg lg:size-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
37
apps/website/src/components/product/api/ReasonSection.vue
Normal file
37
apps/website/src/components/product/api/ReasonSection.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<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('api.reason.1.title', locale),
|
||||
description: t('api.reason.1.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('api.reason.2.title', locale),
|
||||
description: t('api.reason.2.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('api.reason.3.title', locale),
|
||||
description: t('api.reason.3.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('api.reason.4.title', locale),
|
||||
description: t('api.reason.4.description', locale)
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SharedReasonSection
|
||||
:heading="t('api.reason.heading', locale)"
|
||||
:heading-highlight="t('api.reason.headingHighlight', locale)"
|
||||
:heading-suffix="t('api.reason.headingSuffix', locale)"
|
||||
:subtitle="t('api.reason.subtitle', locale)"
|
||||
:reasons="reasons"
|
||||
/>
|
||||
</template>
|
||||
93
apps/website/src/components/product/api/StepsSection.vue
Normal file
93
apps/website/src/components/product/api/StepsSection.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardGridSection from '../shared/CardGridSection.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: '01',
|
||||
titleKey: 'api.steps.step1.title' as const,
|
||||
descriptionKey: 'api.steps.step1.description' as const
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
titleKey: 'api.steps.step2.title' as const,
|
||||
descriptionKey: 'api.steps.step2.description' as const
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
titleKey: 'api.steps.step3.title' as const,
|
||||
descriptionKey: 'api.steps.step3.description' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardGridSection :heading="t('api.steps.heading', locale)" :columns="3">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.number"
|
||||
class="bg-primary-comfy-ink flex aspect-square flex-col rounded-3xl border border-white/10 p-6"
|
||||
>
|
||||
<!-- Isometric illustration area -->
|
||||
<div class="flex flex-1 items-center justify-center" aria-hidden="true">
|
||||
<div
|
||||
class="grid grid-cols-5 gap-0.5"
|
||||
style="transform: rotateX(55deg) rotateZ(45deg)"
|
||||
>
|
||||
<span
|
||||
v-for="i in 15"
|
||||
:key="i"
|
||||
class="block size-5 rounded-sm"
|
||||
:class="[
|
||||
i === 3 || i === 8 || i === 13
|
||||
? 'bg-primary-comfy-yellow'
|
||||
: i % 3 === 0
|
||||
? 'bg-primary-comfy-plum'
|
||||
: 'bg-secondary-mauve'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<p class="text-primary-comfy-yellow text-sm font-bold tracking-wider">
|
||||
{{ step.number }}
|
||||
</p>
|
||||
<h3 class="text-primary-comfy-canvas mt-2 text-xl font-semibold">
|
||||
{{ t(step.titleKey, locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t(step.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA buttons -->
|
||||
<template #footer>
|
||||
<div
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<a
|
||||
:href="externalLinks.app"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-full px-8 py-4 text-center text-sm font-bold tracking-wider transition-opacity hover:opacity-90 lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ t('api.hero.getApiKeys', locale) }}
|
||||
</a>
|
||||
<a
|
||||
:href="externalLinks.docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink w-full rounded-full border px-8 py-4 text-center text-sm font-bold tracking-wider transition-colors lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ t('api.hero.viewDocs', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</CardGridSection>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardGridSection from '../shared/CardGridSection.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards = [
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card1.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card1.description' as const
|
||||
},
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card2.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card2.description' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardGridSection
|
||||
:heading="t('enterprise.byoKey.heading', locale)"
|
||||
:subtitle="t('enterprise.byoKey.subtitle', locale)"
|
||||
:columns="2"
|
||||
>
|
||||
<div
|
||||
v-for="(card, i) in cards"
|
||||
:key="i"
|
||||
class="bg-primary-comfy-ink flex aspect-square flex-col rounded-3xl border border-white/10 p-6"
|
||||
>
|
||||
<!-- Isometric illustration area -->
|
||||
<div class="flex flex-1 items-center justify-center" aria-hidden="true">
|
||||
<div
|
||||
class="grid grid-cols-5 gap-0.5"
|
||||
style="transform: rotateX(55deg) rotateZ(45deg)"
|
||||
>
|
||||
<span
|
||||
v-for="j in 15"
|
||||
:key="j"
|
||||
class="block size-5 rounded-sm"
|
||||
:class="[
|
||||
j === 3 || j === 8 || j === 13
|
||||
? 'bg-primary-comfy-yellow'
|
||||
: j % 3 === 0
|
||||
? 'bg-primary-comfy-plum'
|
||||
: 'bg-secondary-mauve'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<h3 class="text-primary-comfy-canvas mt-2 text-xl font-semibold">
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</CardGridSection>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="relative overflow-hidden px-4 py-24 lg:px-20 lg:py-32">
|
||||
<!-- Decorative images -->
|
||||
<!-- Top-left -->
|
||||
<div
|
||||
class="absolute top-0 -left-8 size-36 rounded-3xl bg-smoke-200 lg:left-[10%] lg:size-64"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Top-right -->
|
||||
<div
|
||||
class="absolute top-0 -right-8 size-28 rounded-3xl bg-smoke-200 lg:top-4 lg:right-[2%] lg:size-40"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Middle-left -->
|
||||
<div
|
||||
class="absolute top-1/3 -left-12 size-40 rounded-3xl bg-smoke-200 lg:top-[35%] lg:left-[2%] lg:h-56 lg:w-48"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Middle-right -->
|
||||
<div
|
||||
class="absolute top-[45%] -right-4 hidden h-48 w-72 rounded-3xl bg-smoke-200 lg:block"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Bottom-right -->
|
||||
<div
|
||||
class="absolute -right-4 bottom-8 hidden size-48 rounded-3xl bg-smoke-200 lg:block"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Text content -->
|
||||
<div
|
||||
class="relative z-10 mx-auto flex max-w-3xl flex-col items-center py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-5xl font-light lg:text-8xl">
|
||||
{{ t('enterprise.ownership.line1', locale) }}
|
||||
</h2>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-5xl font-light lg:mt-10 lg:text-8xl"
|
||||
>
|
||||
{{ t('enterprise.ownership.line2', locale) }}
|
||||
</h2>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-5xl font-light lg:mt-10 lg:text-8xl"
|
||||
>
|
||||
{{ t('enterprise.ownership.line3', locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-12 max-w-xl text-sm lg:mt-16 lg:text-base"
|
||||
>
|
||||
{{ t('enterprise.ownership.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
122
apps/website/src/components/product/enterprise/HeroSection.vue
Normal file
122
apps/website/src/components/product/enterprise/HeroSection.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { t } from '../../../i18n/translations'
|
||||
import ProductHeroBadge from '../../common/ProductHeroBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</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 text="ENTERPRISE" />
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-8 text-5xl/tight font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t('enterprise.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('enterprise.hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-10 flex w-full max-w-md flex-col lg:w-auto lg:max-w-none"
|
||||
>
|
||||
<a
|
||||
href="/contact"
|
||||
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('enterprise.hero.contactSales', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Illustration -->
|
||||
<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"
|
||||
>
|
||||
<!-- Background hexagonal outlines -->
|
||||
<div
|
||||
class="border-secondary-mauve/20 absolute top-12 size-64 rounded-3xl border lg:top-8 lg:size-80"
|
||||
/>
|
||||
<div
|
||||
class="border-secondary-mauve/20 absolute top-20 size-56 rounded-3xl border lg:top-16 lg:size-72"
|
||||
/>
|
||||
|
||||
<!-- Isometric node clusters -->
|
||||
<div class="relative z-10 mt-20 flex gap-4 lg:mt-0 lg:gap-6">
|
||||
<div
|
||||
class="grid grid-cols-3 gap-0.5"
|
||||
style="transform: rotateX(55deg) rotateZ(45deg)"
|
||||
>
|
||||
<span
|
||||
class="bg-secondary-mauve block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-warm-gray block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-secondary-mauve block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-secondary-mauve block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-0.5"
|
||||
style="transform: rotateX(55deg) rotateZ(45deg)"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-warm-gray block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-secondary-mauve block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-secondary-mauve block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-warm-gray block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-secondary-mauve block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-plum block size-7 rounded-lg lg:size-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl flex flex-col gap-8 p-2 lg:flex-row lg:items-stretch lg:gap-16"
|
||||
>
|
||||
<!-- Image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div
|
||||
class="aspect-square w-full rounded-4xl bg-smoke-200"
|
||||
role="img"
|
||||
:aria-label="t('enterprise.orchestration.heading', locale)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="flex flex-col justify-between p-6 lg:w-1/2">
|
||||
<div>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('enterprise.orchestration.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-yellow mt-8 text-lg font-semibold lg:mt-10 lg:text-2xl"
|
||||
>
|
||||
{{ t('enterprise.orchestration.highlight', locale) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-6 text-sm text-smoke-700 lg:text-base">
|
||||
{{ t('enterprise.orchestration.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-sm text-smoke-500 italic lg:text-base">
|
||||
{{ t('enterprise.orchestration.quote', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 lg:mt-0">
|
||||
<a
|
||||
href="/contact"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink inline-block rounded-full px-8 py-4 text-sm font-bold tracking-wider transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('enterprise.hero.contactSales', locale) }}
|
||||
</a>
|
||||
<p class="mt-4 text-xs text-smoke-500 lg:text-sm">
|
||||
{{ t('enterprise.orchestration.footer', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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('enterprise.reason.1.title', locale),
|
||||
description: t('enterprise.reason.1.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('enterprise.reason.2.title', locale),
|
||||
description: t('enterprise.reason.2.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('enterprise.reason.3.title', locale),
|
||||
description: t('enterprise.reason.3.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('enterprise.reason.4.title', locale),
|
||||
description: t('enterprise.reason.4.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('enterprise.reason.5.title', locale),
|
||||
description: t('enterprise.reason.5.description', locale)
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SharedReasonSection
|
||||
:heading="t('enterprise.reason.heading', locale)"
|
||||
:subtitle="t('enterprise.reason.subtitle', locale)"
|
||||
:reasons="reasons"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { getRoutes } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import FeatureShowcaseSection from '../shared/FeatureShowcaseSection.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: t('enterprise.team.feature1.title', locale),
|
||||
description: t('enterprise.team.feature1.description', locale)
|
||||
},
|
||||
{
|
||||
title: t('enterprise.team.feature2.title', locale),
|
||||
description: t('enterprise.team.feature2.description', locale),
|
||||
ctaText: t('enterprise.team.feature2.cta', locale),
|
||||
ctaHref: routes.cloud
|
||||
},
|
||||
{
|
||||
title: t('enterprise.team.feature3.title', locale),
|
||||
description: t('enterprise.team.feature3.description', locale),
|
||||
ctaText: t('enterprise.hero.contactSales', locale),
|
||||
ctaHref: '/contact'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureShowcaseSection
|
||||
:heading="t('enterprise.team.heading', locale)"
|
||||
:subtitle="t('enterprise.team.subtitle', locale)"
|
||||
:features="features"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<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>
|
||||
16
apps/website/src/components/product/local/FAQSection.vue
Normal file
16
apps/website/src/components/product/local/FAQSection.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
85
apps/website/src/components/product/local/HeroSection.vue
Normal file
85
apps/website/src/components/product/local/HeroSection.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<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>
|
||||
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
35
apps/website/src/components/product/local/ReasonSection.vue
Normal file
35
apps/website/src/components/product/local/ReasonSection.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
heading: string
|
||||
subtitle?: string
|
||||
columns?: 2 | 3
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-4 text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-12 grid gap-6 lg:mt-16"
|
||||
:class="columns === 2 ? 'lg:grid-cols-2' : 'lg:grid-cols-3'"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<slot name="footer" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface Feature {
|
||||
title: string
|
||||
description: string
|
||||
description2?: string
|
||||
ctaText?: string
|
||||
ctaHref?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subtitle: string
|
||||
features: Feature[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-24 lg:px-20">
|
||||
<!-- Section header -->
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<p class="mt-4 text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="mt-24 flex flex-col gap-4 lg:gap-8">
|
||||
<div
|
||||
v-for="(feature, i) in features"
|
||||
:key="i"
|
||||
class="bg-transparency-white-t4 rounded-5xl flex flex-col gap-8 p-2 lg:flex-row lg:items-stretch lg:gap-12"
|
||||
>
|
||||
<!-- Text -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col p-6 lg:w-1/2 lg:justify-between',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<h3 class="text-primary-comfy-canvas text-2xl font-light lg:text-3xl">
|
||||
{{ feature.title }}
|
||||
</h3>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<p class="text-sm text-smoke-700 lg:text-base">
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
<p
|
||||
v-if="feature.description2"
|
||||
class="mt-4 text-sm text-smoke-700 lg:text-base"
|
||||
>
|
||||
{{ feature.description2 }}
|
||||
</p>
|
||||
<a
|
||||
v-if="feature.ctaText && feature.ctaHref"
|
||||
:href="feature.ctaHref"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-6 inline-block rounded-full px-6 py-3 text-xs font-bold tracking-wider transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ feature.ctaText }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div
|
||||
:class="
|
||||
cn('order-1 lg:w-1/2', i % 2 === 0 ? 'lg:order-2' : 'lg:order-1')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="aspect-4/3 w-full rounded-4xl bg-smoke-200"
|
||||
role="img"
|
||||
:aria-label="feature.title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
62
apps/website/src/components/product/shared/ReasonSection.vue
Normal file
62
apps/website/src/components/product/shared/ReasonSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
interface Reason {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const {
|
||||
heading,
|
||||
headingHighlight = '',
|
||||
headingSuffix = '',
|
||||
subtitle = '',
|
||||
reasons
|
||||
} = defineProps<{
|
||||
heading: string
|
||||
headingHighlight?: string
|
||||
headingSuffix?: string
|
||||
subtitle?: 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
|
||||
>{{ headingSuffix }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="text-primary-comfy-canvas/70 mt-6 text-sm">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</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>
|
||||
28
apps/website/src/composables/useDownloadUrl.ts
Normal file
28
apps/website/src/composables/useDownloadUrl.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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() }
|
||||
}
|
||||
@@ -59,8 +59,9 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
|
||||
|
||||
function cacheLayout() {
|
||||
const contentRect = content.getBoundingClientRect()
|
||||
const sectionStyle = getComputedStyle(section)
|
||||
contentH = content.scrollHeight
|
||||
vpH = window.innerHeight
|
||||
vpH = window.innerHeight - parseFloat(sectionStyle.paddingTop)
|
||||
buttonCenters = Array.from(nav.querySelectorAll(':scope > *')).map(
|
||||
(btn) => {
|
||||
const btnRect = btn.getBoundingClientRect()
|
||||
|
||||
@@ -109,7 +109,7 @@ const translations = {
|
||||
'zh-CN': '启动云端'
|
||||
},
|
||||
'getStarted.step1.or': {
|
||||
en: 'or',
|
||||
en: ' or ',
|
||||
'zh-CN': '或'
|
||||
},
|
||||
'getStarted.step2.title': {
|
||||
@@ -134,6 +134,10 @@ 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 创作引擎'
|
||||
@@ -218,6 +222,498 @@ const translations = {
|
||||
en: "Comfy gives you the building blocks to create workflows nobody's imagined yet — and share them with everyone.",
|
||||
'zh-CN': 'Comfy 为您提供构建模块,创造出前所未有的工作流——并与所有人分享。'
|
||||
},
|
||||
|
||||
// API – HeroSection
|
||||
'api.hero.heading': {
|
||||
en: 'Turn any workflow into\na production endpoint.',
|
||||
'zh-CN': '将任何工作流转化为\n生产级端点。'
|
||||
},
|
||||
'api.hero.subtitle': {
|
||||
en: 'Design your workflows. Deploy them as API calls. Automate generation, integrate with your systems, and scale to thousands of outputs on Comfy Cloud.',
|
||||
'zh-CN':
|
||||
'设计你的工作流。将它们部署为 API 调用。自动化生成、与你的系统集成,并在 Comfy Cloud 上扩展到数千个输出。'
|
||||
},
|
||||
'api.hero.getApiKeys': {
|
||||
en: 'GET API KEYS',
|
||||
'zh-CN': '获取 API 密钥'
|
||||
},
|
||||
'api.hero.viewDocs': {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
|
||||
// Enterprise – TeamSection
|
||||
'enterprise.team.heading': {
|
||||
en: 'Team workspaces\nand shared assets.',
|
||||
'zh-CN': '团队工作区\n与共享资产。'
|
||||
},
|
||||
'enterprise.team.subtitle': {
|
||||
en: 'Organize workflows, models, and outputs in shared workspaces. Control who builds, who runs, and who deploys.',
|
||||
'zh-CN':
|
||||
'在共享工作区中组织工作流、模型和输出。控制谁构建、谁运行、谁部署。'
|
||||
},
|
||||
'enterprise.team.feature1.title': {
|
||||
en: 'Role-based access',
|
||||
'zh-CN': '基于角色的访问控制'
|
||||
},
|
||||
'enterprise.team.feature1.description': {
|
||||
en: 'Control who builds, who runs, and who deploys.',
|
||||
'zh-CN': '控制谁构建、谁运行、谁部署。'
|
||||
},
|
||||
'enterprise.team.feature2.title': {
|
||||
en: 'Single Sign-On',
|
||||
'zh-CN': '单点登录'
|
||||
},
|
||||
'enterprise.team.feature2.description': {
|
||||
en: 'Most things you build locally run on Comfy Cloud \u2014 same file, same results, powerful GPUs on demand. When a job outgrows your machine, push it to the cloud. No conversion, no rework.',
|
||||
'zh-CN':
|
||||
'你在本地构建的大部分内容都能在 Comfy Cloud 上运行——相同文件、相同结果、按需使用强大 GPU。当任务超出你的机器能力时,推送到云端。无需转换,无需返工。'
|
||||
},
|
||||
'enterprise.team.feature2.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端特性'
|
||||
},
|
||||
'enterprise.team.feature3.title': {
|
||||
en: 'App Mode',
|
||||
'zh-CN': 'App 模式'
|
||||
},
|
||||
'enterprise.team.feature3.description': {
|
||||
en: 'Non-technical team members run workflows without touching the node graph.',
|
||||
'zh-CN': '非技术团队成员无需接触节点图即可运行工作流。'
|
||||
},
|
||||
|
||||
// Enterprise – ReasonSection
|
||||
'enterprise.reason.heading': {
|
||||
en: 'Enterprise-grade infrastructure for the creative engine inside your organization.',
|
||||
'zh-CN': '为组织内的创作引擎提供企业级基础设施。'
|
||||
},
|
||||
'enterprise.reason.subtitle': {
|
||||
en: 'Comfy Cloud API gives you a managed infrastructure so you can focus on the workflow, not the hardware.',
|
||||
'zh-CN':
|
||||
'Comfy Cloud API 为你提供托管基础设施,让你专注于工作流,而非硬件。'
|
||||
},
|
||||
'enterprise.reason.1.title': {
|
||||
en: 'Dedicated GPU compute',
|
||||
'zh-CN': '专属 GPU 算力'
|
||||
},
|
||||
'enterprise.reason.1.description': {
|
||||
en: 'Reserved server-grade, powerful compute for your organization.',
|
||||
'zh-CN': '为你的组织预留的服务器级强大算力。'
|
||||
},
|
||||
'enterprise.reason.2.title': {
|
||||
en: 'Priority queuing',
|
||||
'zh-CN': '优先队列'
|
||||
},
|
||||
'enterprise.reason.2.description': {
|
||||
en: 'Your production jobs run first.',
|
||||
'zh-CN': '你的生产任务优先运行。'
|
||||
},
|
||||
'enterprise.reason.3.title': {
|
||||
en: 'Flexible Deployment',
|
||||
'zh-CN': '灵活部署'
|
||||
},
|
||||
'enterprise.reason.3.description': {
|
||||
en: 'Same workflows on Comfy Cloud. Scale compute up or down based on your production schedule.',
|
||||
'zh-CN': '在 Comfy Cloud 上运行相同的工作流。根据生产计划弹性扩缩算力。'
|
||||
},
|
||||
'enterprise.reason.4.title': {
|
||||
en: 'Custom SLAs',
|
||||
'zh-CN': '自定义 SLA'
|
||||
},
|
||||
'enterprise.reason.4.description': {
|
||||
en: 'Uptime and response commitments built around your schedule.',
|
||||
'zh-CN': '根据你的时间安排定制正常运行时间和响应承诺。'
|
||||
},
|
||||
'enterprise.reason.5.title': {
|
||||
en: 'Commercial license guaranteed',
|
||||
'zh-CN': '商业许可保障'
|
||||
},
|
||||
'enterprise.reason.5.description': {
|
||||
en: 'Every model available through Comfy Cloud is cleared for commercial use. No license ambiguity for your legal team.',
|
||||
'zh-CN':
|
||||
'通过 Comfy Cloud 提供的每个模型均已获得商业使用许可。法务团队无需担心许可歧义。'
|
||||
},
|
||||
|
||||
// Enterprise – HeroSection
|
||||
'enterprise.hero.heading': {
|
||||
en: 'Your team already runs ComfyUI. Scale it with confidence.',
|
||||
'zh-CN': '你的团队已经在使用 ComfyUI。放心地扩展它。'
|
||||
},
|
||||
'enterprise.hero.subtitle': {
|
||||
en: 'ComfyUI Enterprise adds managed infrastructure, team controls, and dedicated support to the workflows your organization already builds.',
|
||||
'zh-CN':
|
||||
'ComfyUI 企业版为你的组织已有的工作流添加托管基础设施、团队控制和专属支持。'
|
||||
},
|
||||
'enterprise.hero.contactSales': {
|
||||
en: 'CONTACT SALES',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// Enterprise – DataOwnershipSection
|
||||
'enterprise.ownership.line1': {
|
||||
en: 'Your data.',
|
||||
'zh-CN': '你的数据。'
|
||||
},
|
||||
'enterprise.ownership.line2': {
|
||||
en: 'Your network.',
|
||||
'zh-CN': '你的网络。'
|
||||
},
|
||||
'enterprise.ownership.line3': {
|
||||
en: 'Your terms.',
|
||||
'zh-CN': '你的条款。'
|
||||
},
|
||||
'enterprise.ownership.subtitle': {
|
||||
en: 'Your workflows, models, and generated outputs stay within your organization\u2019s environment. Role-based access controls and data isolation built for organizations with the strictest requirements.',
|
||||
'zh-CN':
|
||||
'你的工作流、模型和生成输出始终保留在你的组织环境中。基于角色的访问控制和数据隔离,为最严格要求的组织而构建。'
|
||||
},
|
||||
|
||||
// Enterprise – BYOKeySection
|
||||
'enterprise.byoKey.heading': {
|
||||
en: 'Bring your own API key',
|
||||
'zh-CN': '自带 API 密钥'
|
||||
},
|
||||
'enterprise.byoKey.subtitle': {
|
||||
en: 'Use your own contracts with third-party model providers. Comfy orchestrates the pipeline. You choose which models to run and whose API keys to use.',
|
||||
'zh-CN':
|
||||
'使用你与第三方模型提供商的合约。Comfy 编排管线。你决定运行哪些模型、使用谁的 API 密钥。'
|
||||
},
|
||||
'enterprise.byoKey.card1.title': {
|
||||
en: 'API key management',
|
||||
'zh-CN': 'API 密钥管理'
|
||||
},
|
||||
'enterprise.byoKey.card1.description': {
|
||||
en: 'Bring your own API keys from any model provider. Use your existing contracts and pricing.',
|
||||
'zh-CN': '从任何模型提供商导入你自己的 API 密钥。使用你现有的合约和定价。'
|
||||
},
|
||||
'enterprise.byoKey.card2.title': {
|
||||
en: 'Real-time progress',
|
||||
'zh-CN': '实时进度'
|
||||
},
|
||||
'enterprise.byoKey.card2.description': {
|
||||
en: 'Step-by-step execution updates via WebSocket.',
|
||||
'zh-CN': '通过 WebSocket 逐步更新执行状态。'
|
||||
},
|
||||
|
||||
// Enterprise – OrchestrationSection
|
||||
'enterprise.orchestration.heading': {
|
||||
en: 'The orchestration layer isn\u2019t worth rebuilding.',
|
||||
'zh-CN': '编排层不值得重建。'
|
||||
},
|
||||
'enterprise.orchestration.highlight': {
|
||||
en: 'Every team that evaluates building this internally reaches the same conclusion.',
|
||||
'zh-CN': '每个评估过内部自建的团队都得出了同样的结论。'
|
||||
},
|
||||
'enterprise.orchestration.description': {
|
||||
en: 'Internal estimates are 12\u201318 months with a dedicated engineering team. No matter how good or how fast, building this internally won\u2019t have 5,000+ extensions, 60,000+ nodes, or same-day model support.',
|
||||
'zh-CN':
|
||||
'内部评估需要一个专属工程团队耗时 12\u201318 个月。无论多优秀多快速,自建方案都不会拥有 5,000+ 扩展、60,000+ 节点或当日模型支持。'
|
||||
},
|
||||
'enterprise.orchestration.quote': {
|
||||
en: 'Platforms like OpenArt and Fal already run Comfy underneath \u2014 because they reached the same conclusion.',
|
||||
'zh-CN':
|
||||
'OpenArt 和 Fal 等平台底层已经运行 Comfy——因为他们得出了同样的结论。'
|
||||
},
|
||||
'enterprise.orchestration.footer': {
|
||||
en: 'Comfy Enterprise plans come with support from the team that builds the engine: direct access to our engineering team and a whiteglove onboarding.',
|
||||
'zh-CN':
|
||||
'Comfy 企业版计划附带引擎开发团队的支持:直接访问我们的工程团队和白手套式入职服务。'
|
||||
},
|
||||
|
||||
// API – StepsSection
|
||||
'api.steps.heading': {
|
||||
en: 'Three steps to production',
|
||||
'zh-CN': '三步进入生产'
|
||||
},
|
||||
'api.steps.step1.title': {
|
||||
en: 'Design the workflow',
|
||||
'zh-CN': '设计工作流'
|
||||
},
|
||||
'api.steps.step1.description': {
|
||||
en: 'Build and test in the ComfyUI interface \u2014 locally or on Comfy Cloud. When it works, export the API format.',
|
||||
'zh-CN':
|
||||
'在 ComfyUI 界面中构建和测试——本地或 Comfy Cloud 上。测试通过后,导出 API 格式。'
|
||||
},
|
||||
'api.steps.step2.title': {
|
||||
en: 'Submit via API',
|
||||
'zh-CN': '通过 API 提交'
|
||||
},
|
||||
'api.steps.step2.description': {
|
||||
en: 'Send the workflow JSON or a single request to the API endpoint. Modify inputs programmatically \u2014 prompts, seeds, images, any parameter on any node.',
|
||||
'zh-CN':
|
||||
'将工作流 JSON 或单个请求发送到 API 端点。以编程方式修改输入——提示词、种子、图像、任何节点上的任何参数。'
|
||||
},
|
||||
'api.steps.step3.title': {
|
||||
en: 'Monitor and Retrieve',
|
||||
'zh-CN': '监控与获取'
|
||||
},
|
||||
'api.steps.step3.description': {
|
||||
en: 'Track progress in real time over WebSocket. Download the finished output when the job completes.',
|
||||
'zh-CN': '通过 WebSocket 实时跟踪进度。作业完成后下载最终输出。'
|
||||
},
|
||||
|
||||
// API – AutomationSection
|
||||
'api.automation.heading': {
|
||||
en: 'The automation layer for\nprofessional-grade visual AI',
|
||||
'zh-CN': '专业级视觉 AI 的\n自动化层'
|
||||
},
|
||||
'api.automation.subtitle': {
|
||||
en: 'To transform ComfyUI from a powerful tool to reliable infrastructure.',
|
||||
'zh-CN': '将 ComfyUI 从强大的工具转化为可靠的基础设施。'
|
||||
},
|
||||
'api.automation.feature1.title': {
|
||||
en: "Automate what can't be manual",
|
||||
'zh-CN': '自动化无法手动完成的工作'
|
||||
},
|
||||
'api.automation.feature1.description': {
|
||||
en: 'Trigger generation when a user uploads a photo. Process an entire product catalog overnight. Swap a character\u2019s expression across 500 frames.',
|
||||
'zh-CN':
|
||||
'当用户上传照片时触发生成。一夜之间处理整个产品目录。在 500 帧中替换角色表情。'
|
||||
},
|
||||
'api.automation.feature1.description2': {
|
||||
en: 'The API is for running workflows inside other systems \u2014 triggered by code, not by a person at a keyboard.',
|
||||
'zh-CN':
|
||||
'API 用于在其他系统内运行工作流——由代码触发,而非由键盘前的人触发。'
|
||||
},
|
||||
'api.automation.feature2.title': {
|
||||
en: 'Inject dynamic values at runtime',
|
||||
'zh-CN': '在运行时注入动态值'
|
||||
},
|
||||
'api.automation.feature2.description': {
|
||||
en: 'Pass a different face, a different product, a different brand palette into the same pipeline \u2014 consistent, controlled output every time.',
|
||||
'zh-CN':
|
||||
'将不同的面孔、不同的产品、不同的品牌色板传入同一管线——每次都获得一致、可控的输出。'
|
||||
},
|
||||
'api.automation.feature2.description2': {
|
||||
en: 'The workflow stays fixed. The inputs change.',
|
||||
'zh-CN': '工作流保持不变。输入可以改变。'
|
||||
},
|
||||
'api.automation.feature3.title': {
|
||||
en: 'Integrate ComfyUI into the rest of your stack',
|
||||
'zh-CN': '将 ComfyUI 集成到你的技术栈中'
|
||||
},
|
||||
'api.automation.feature3.description': {
|
||||
en: 'Generation is usually one step in a larger system. Fetch input from S3, call the ComfyUI API, post-process the result, write it to a database, and notify a team.',
|
||||
'zh-CN':
|
||||
'生成通常只是更大系统中的一个步骤。从 S3 获取输入、调用 ComfyUI API、后处理结果、写入数据库、通知团队。'
|
||||
},
|
||||
|
||||
// API – ReasonSection
|
||||
'api.reason.heading': {
|
||||
en: 'Deploy on\n',
|
||||
'zh-CN': '部署在\n'
|
||||
},
|
||||
'api.reason.headingHighlight': {
|
||||
en: 'Comfy Cloud —\n',
|
||||
'zh-CN': 'Comfy Cloud——\n'
|
||||
},
|
||||
'api.reason.headingSuffix': {
|
||||
en: 'no servers to\nmanage.',
|
||||
'zh-CN': '无需管理\n服务器。'
|
||||
},
|
||||
'api.reason.subtitle': {
|
||||
en: 'Comfy Cloud API gives you a managed infrastructure so you can focus on the workflow, not the hardware.',
|
||||
'zh-CN':
|
||||
'Comfy Cloud API 为你提供托管基础设施,让你专注于工作流,而非硬件。'
|
||||
},
|
||||
'api.reason.1.title': {
|
||||
en: 'Powerful GPUs on demand',
|
||||
'zh-CN': '按需使用强大 GPU'
|
||||
},
|
||||
'api.reason.1.description': {
|
||||
en: 'Heavy video generation, complex multi-step pipelines, large batch jobs.',
|
||||
'zh-CN': '重度视频生成、复杂多步管线、大批量任务。'
|
||||
},
|
||||
'api.reason.2.title': {
|
||||
en: 'One API key. Immediate access.',
|
||||
'zh-CN': '一个 API 密钥。即时访问。'
|
||||
},
|
||||
'api.reason.2.description': {
|
||||
en: 'No environment setup, no model downloads, no GPU procurement.',
|
||||
'zh-CN': '无需环境配置、无需下载模型、无需采购 GPU。'
|
||||
},
|
||||
'api.reason.3.title': {
|
||||
en: 'Open models & partner models through one endpoint',
|
||||
'zh-CN': '通过一个端点访问开源模型和合作伙伴模型'
|
||||
},
|
||||
'api.reason.3.description': {
|
||||
en: 'Open-source models alongside partner models like Kling, Luma, Nano Banana, Grok, and Runway \u2014 all with one credit balance.',
|
||||
'zh-CN':
|
||||
'开源模型与 Kling、Luma、Nano Banana、Grok、Runway 等合作伙伴模型并存——全部使用同一额度。'
|
||||
},
|
||||
'api.reason.4.title': {
|
||||
en: 'Real-time progress',
|
||||
'zh-CN': '实时进度'
|
||||
},
|
||||
'api.reason.4.description': {
|
||||
en: 'Step-by-step execution updates and live previews during generation via WebSocket.',
|
||||
'zh-CN': '通过 WebSocket 获取逐步执行更新和生成过程中的实时预览。'
|
||||
},
|
||||
|
||||
// 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': '事物' },
|
||||
@@ -278,4 +774,4 @@ export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
|
||||
export type { Locale }
|
||||
export type { Locale, TranslationKey }
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../components/common/ComingSoon.astro'
|
||||
import CloudBannerSection from '../components/product/shared/CloudBannerSection.vue'
|
||||
import HeroSection from '../components/product/api/HeroSection.vue'
|
||||
import AutomationSection from '../components/product/api/AutomationSection.vue'
|
||||
import StepsSection from '../components/product/api/StepsSection.vue'
|
||||
import ReasonSection from '../components/product/api/ReasonSection.vue'
|
||||
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy API">
|
||||
<ComingSoon />
|
||||
<CloudBannerSection />
|
||||
<HeroSection />
|
||||
<AutomationSection />
|
||||
<StepsSection />
|
||||
<ReasonSection />
|
||||
<ProductCardsSection exclude-product="api" label-key="products.labelProducts" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
import CloudBannerSection from '../../components/product/shared/CloudBannerSection.vue'
|
||||
import HeroSection from '../../components/product/enterprise/HeroSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import ReasonSection from '../../components/product/enterprise/ReasonSection.vue'
|
||||
import TeamSection from '../../components/product/enterprise/TeamSection.vue'
|
||||
import DataOwnershipSection from '../../components/product/enterprise/DataOwnershipSection.vue'
|
||||
import BYOKeySection from '../../components/product/enterprise/BYOKeySection.vue'
|
||||
import OrchestrationSection from '../../components/product/enterprise/OrchestrationSection.vue'
|
||||
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy Enterprise">
|
||||
<ComingSoon />
|
||||
<CloudBannerSection />
|
||||
<HeroSection />
|
||||
<SocialProofBarSection />
|
||||
<ReasonSection />
|
||||
<TeamSection />
|
||||
<DataOwnershipSection />
|
||||
<BYOKeySection />
|
||||
<OrchestrationSection />
|
||||
<ProductCardsSection exclude-product="enterprise" label-key="products.labelProducts" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../components/common/ComingSoon.astro'
|
||||
import HeroSection from '../components/product/local/HeroSection.vue'
|
||||
import CloudBannerSection from '../components/product/shared/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'
|
||||
---
|
||||
|
||||
<BaseLayout title="Download Comfy — Run AI Locally">
|
||||
<ComingSoon />
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
<ReasonSection />
|
||||
<EcoSystemSection client:visible />
|
||||
<ProductCardsSection />
|
||||
<FAQSection client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../components/home/ProductCardsSection.vue'
|
||||
import HeroSection from '../components/home/HeroSection.vue'
|
||||
import SocialProofBarSection from '../components/home/SocialProofBarSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
import UseCaseSection from '../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
|
||||
@@ -13,8 +13,8 @@ import BuildWhatSection from '../components/home/BuildWhatSection.vue'
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection client:visible />
|
||||
<UseCaseSection client:visible />
|
||||
<ProductShowcaseSection client:load />
|
||||
<UseCaseSection client:load />
|
||||
<GetStartedSection />
|
||||
<ProductCardsSection />
|
||||
<CaseStudySpotlightSection />
|
||||
|
||||
18
apps/website/src/pages/zh-CN/api.astro
Normal file
18
apps/website/src/pages/zh-CN/api.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CloudBannerSection from '../../components/product/shared/CloudBannerSection.vue'
|
||||
import HeroSection from '../../components/product/api/HeroSection.vue'
|
||||
import AutomationSection from '../../components/product/api/AutomationSection.vue'
|
||||
import StepsSection from '../../components/product/api/StepsSection.vue'
|
||||
import ReasonSection from '../../components/product/api/ReasonSection.vue'
|
||||
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy API">
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
<HeroSection locale="zh-CN" />
|
||||
<AutomationSection locale="zh-CN" />
|
||||
<StepsSection locale="zh-CN" />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
<ProductCardsSection locale="zh-CN" exclude-product="api" label-key="products.labelProducts" />
|
||||
</BaseLayout>
|
||||
16
apps/website/src/pages/zh-CN/cloud/enterprise.astro
Normal file
16
apps/website/src/pages/zh-CN/cloud/enterprise.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import CloudBannerSection from '../../../components/product/shared/CloudBannerSection.vue'
|
||||
import HeroSection from '../../../components/product/enterprise/HeroSection.vue'
|
||||
import SocialProofBarSection from '../../../components/common/SocialProofBarSection.vue'
|
||||
import ReasonSection from '../../../components/product/enterprise/ReasonSection.vue'
|
||||
import TeamSection from '../../../components/product/enterprise/TeamSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy Enterprise">
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
<HeroSection locale="zh-CN" />
|
||||
<SocialProofBarSection />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
<TeamSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -1,76 +1,18 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
|
||||
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,
|
||||
},
|
||||
]
|
||||
import CloudBannerSection from '../../components/product/shared/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'
|
||||
---
|
||||
|
||||
<BaseLayout title="下载 — Comfy">
|
||||
<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>
|
||||
<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 />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../../components/home/ProductCardsSection.vue'
|
||||
import HeroSection from '../../components/home/HeroSection.vue'
|
||||
import SocialProofBarSection from '../../components/home/SocialProofBarSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
|
||||
import UseCaseSection from '../../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
|
||||
|
||||
@@ -113,3 +113,16 @@
|
||||
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,5 +6,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "e2e/**/*", "astro.config.ts"],
|
||||
"exclude": ["src/**/*.stories.ts"]
|
||||
"exclude": ["src/**/*.stories.ts"],
|
||||
"references": [{ "path": "./tsconfig.stories.json" }]
|
||||
}
|
||||
|
||||
16
apps/website/tsconfig.stories.json
Normal file
16
apps/website/tsconfig.stories.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.stories.ts"]
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
JobHistorySidebarTab,
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
@@ -31,6 +30,9 @@ import {
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
@@ -63,7 +65,6 @@ class ComfyPropertiesPanel {
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _jobHistoryTab: JobHistorySidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTabV2: NodeLibrarySidebarTabV2 | null = null
|
||||
@@ -102,11 +103,6 @@ class ComfyMenu {
|
||||
return this._assetsTab
|
||||
}
|
||||
|
||||
get jobHistoryTab() {
|
||||
this._jobHistoryTab ??= new JobHistorySidebarTab(this.page)
|
||||
return this._jobHistoryTab
|
||||
}
|
||||
|
||||
get workflowsTab() {
|
||||
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
|
||||
return this._workflowsTab
|
||||
@@ -182,6 +178,8 @@ export class ComfyPage {
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
public readonly visibleToasts: Locator
|
||||
@@ -234,6 +232,8 @@ export class ComfyPage {
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
@@ -499,6 +499,7 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
export const assetApiFixture = base.extend<{
|
||||
assetApi: AssetHelper
|
||||
}>({
|
||||
assetApi: async ({ page }, use) => {
|
||||
const assetApi = createAssetHelper(page)
|
||||
|
||||
await use(assetApi)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
}
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { jobsBackendFixture } from '@e2e/fixtures/jobsBackendFixture'
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
|
||||
export const assetScenarioFixture = jobsBackendFixture.extend<{
|
||||
assetScenario: AssetScenarioHelper
|
||||
}>({
|
||||
assetScenario: async ({ page, jobsBackend }, use) => {
|
||||
const assetScenario = new AssetScenarioHelper(page, jobsBackend)
|
||||
|
||||
await use(assetScenario)
|
||||
|
||||
await assetScenario.clear()
|
||||
}
|
||||
})
|
||||
@@ -204,43 +204,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class JobHistorySidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly searchInput: Locator
|
||||
public readonly allTab: Locator
|
||||
public readonly completedTab: Locator
|
||||
public readonly failedTab: Locator
|
||||
public readonly moreOptionsButton: Locator
|
||||
public readonly clearQueuedButton: Locator
|
||||
public readonly jobRows: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'job-history')
|
||||
this.root = page.locator('.sidebar-content-container')
|
||||
this.searchInput = this.root.getByPlaceholder('Search...')
|
||||
this.allTab = this.root.getByRole('tab', { name: 'All', exact: true })
|
||||
this.completedTab = this.root.getByRole('tab', {
|
||||
name: 'Completed',
|
||||
exact: true
|
||||
})
|
||||
this.failedTab = this.root.getByRole('tab', { name: 'Failed', exact: true })
|
||||
this.moreOptionsButton = this.root.getByLabel('More options')
|
||||
this.clearQueuedButton = this.root.getByRole('button', {
|
||||
name: 'Clear queue'
|
||||
})
|
||||
this.jobRows = this.root.locator('[data-job-id]')
|
||||
}
|
||||
|
||||
jobRow(jobId: string) {
|
||||
return this.root.locator(`[data-job-id="${jobId}"]`)
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.searchInput.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly modelTree: Locator
|
||||
@@ -286,62 +249,70 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
// --- Tab navigation ---
|
||||
public readonly generatedTab: Locator
|
||||
public readonly importedTab: Locator
|
||||
|
||||
// --- Empty state ---
|
||||
public readonly emptyStateMessage: Locator
|
||||
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
public readonly backToAssetsButton: Locator
|
||||
public readonly copyJobIdButton: Locator
|
||||
public readonly previewDialog: Locator
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
public readonly selectedCards: Locator
|
||||
|
||||
// --- List view items ---
|
||||
public readonly listViewItems: Locator
|
||||
|
||||
// --- Selection footer ---
|
||||
public readonly selectionFooter: Locator
|
||||
public readonly selectionCountButton: Locator
|
||||
public readonly deselectAllButton: Locator
|
||||
public readonly deleteSelectedButton: Locator
|
||||
public readonly downloadSelectedButton: Locator
|
||||
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
|
||||
// --- Loading ---
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
this.root = page.locator('.sidebar-content-container')
|
||||
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
|
||||
this.importedTab = page.getByRole('tab', { name: 'Imported' })
|
||||
this.emptyStateMessage = page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
this.searchInput = this.root.getByPlaceholder(/Search Assets/i)
|
||||
this.settingsButton = this.root.getByLabel('View settings')
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.backToAssetsButton = page.getByRole('button', {
|
||||
name: 'Back to all assets'
|
||||
})
|
||||
this.copyJobIdButton = page.getByRole('button', {
|
||||
name: 'Copy job ID'
|
||||
})
|
||||
this.previewDialog = page.getByRole('dialog', { name: 'Gallery' })
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.assetCards = this.root
|
||||
this.assetCards = page
|
||||
.getByRole('button')
|
||||
.and(this.root.locator('[data-selected]'))
|
||||
this.selectedCards = this.root.locator('[data-selected="true"]')
|
||||
this.listViewItems = this.root.getByRole('button', { name: /asset$/i })
|
||||
this.selectionFooter = this.root.locator('..').getByRole('toolbar', {
|
||||
name: 'Selected asset actions'
|
||||
})
|
||||
this.selectionCountButton = this.root
|
||||
.getByRole('button', { name: /Assets Selected:/ })
|
||||
.or(page.getByText(/Assets Selected: \d+/))
|
||||
.first()
|
||||
.and(page.locator('[data-selected]'))
|
||||
this.selectedCards = page.locator('[data-selected="true"]')
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
@@ -351,113 +322,28 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.skeletonLoaders = this.root.locator('.animate-pulse')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
previewImage(filename: string) {
|
||||
return this.previewDialog.getByRole('img', { name: filename })
|
||||
}
|
||||
|
||||
asset(name: string) {
|
||||
return this.getAssetCardByName(name)
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.assetCards.filter({ hasText: name }).first()
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
contextMenuAction(label: string) {
|
||||
return this.contextMenuItem(label)
|
||||
}
|
||||
|
||||
async showGenerated() {
|
||||
await this.switchToGenerated()
|
||||
}
|
||||
|
||||
async showImported() {
|
||||
await this.switchToImported()
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.searchInput.fill(query)
|
||||
}
|
||||
|
||||
async switchToListView() {
|
||||
await this.openSettingsMenu()
|
||||
await this.listViewOption.click()
|
||||
}
|
||||
|
||||
async switchToGridView() {
|
||||
await this.openSettingsMenu()
|
||||
await this.gridViewOption.click()
|
||||
}
|
||||
|
||||
async openContextMenuForAsset(name: string) {
|
||||
await this.asset(name).click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async runContextMenuAction(assetName: string, actionName: string) {
|
||||
await this.openContextMenuForAsset(assetName)
|
||||
await this.contextMenuAction(actionName).click()
|
||||
}
|
||||
|
||||
async openAssetPreview(name: string) {
|
||||
const asset = this.asset(name)
|
||||
await asset.hover()
|
||||
|
||||
const zoomButton = asset.getByLabel('Zoom in')
|
||||
if (await zoomButton.isVisible().catch(() => false)) {
|
||||
await zoomButton.click()
|
||||
return
|
||||
}
|
||||
|
||||
await asset.dblclick()
|
||||
}
|
||||
|
||||
async openOutputFolder(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await this.backToAssetsButton.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async toggleStack(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
}
|
||||
|
||||
async selectAssets(names: string[]) {
|
||||
if (names.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.asset(names[0]).click()
|
||||
|
||||
for (const name of names.slice(1)) {
|
||||
await this.asset(name).click({
|
||||
modifiers: ['ControlOrMeta']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.root.waitFor({ state: 'visible' })
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import {
|
||||
buildSeededFileKey,
|
||||
buildSeededFiles,
|
||||
defaultFileFor
|
||||
} from '@e2e/fixtures/helpers/seededAssetFiles'
|
||||
import type { SeededAssetFile } from '@e2e/fixtures/helpers/seededAssetFiles'
|
||||
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
|
||||
const DEFAULT_FIXTURE_CREATE_TIME = Date.UTC(2024, 0, 1, 0, 0, 0)
|
||||
|
||||
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
|
||||
filename?: string
|
||||
subfolder?: string
|
||||
type?: GeneratedOutputFixture['type']
|
||||
nodeId: string
|
||||
mediaType?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
function normalizeOutputFixture(
|
||||
output: GeneratedOutputFixture
|
||||
): GeneratedOutputFixture {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
return {
|
||||
mediaType: 'images',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
...output,
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputFilename(baseFilename: string, index: number): string {
|
||||
if (index === 0) {
|
||||
return baseFilename
|
||||
}
|
||||
|
||||
const extensionIndex = baseFilename.lastIndexOf('.')
|
||||
if (extensionIndex === -1) {
|
||||
return `${baseFilename}-${index + 1}`
|
||||
}
|
||||
|
||||
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
|
||||
}
|
||||
|
||||
function getPreviewOutput(
|
||||
previewOutput: JobEntry['preview_output'] | undefined
|
||||
): MockPreviewOutput | undefined {
|
||||
return previewOutput as MockPreviewOutput | undefined
|
||||
}
|
||||
|
||||
function outputsFromJobEntry(
|
||||
job: JobEntry
|
||||
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
|
||||
const previewOutput = getPreviewOutput(job.preview_output)
|
||||
const outputCount = Math.max(job.outputs_count ?? 1, 1)
|
||||
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
|
||||
const mediaType: GeneratedOutputFixture['mediaType'] =
|
||||
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
|
||||
? previewOutput.mediaType
|
||||
: 'images'
|
||||
const outputs = Array.from({ length: outputCount }, (_, index) => ({
|
||||
filename: createOutputFilename(baseFilename, index),
|
||||
displayName: index === 0 ? previewOutput?.display_name : undefined,
|
||||
mediaType,
|
||||
subfolder: previewOutput?.subfolder ?? '',
|
||||
type: previewOutput?.type ?? 'output'
|
||||
}))
|
||||
|
||||
return [outputs[0], ...outputs.slice(1)]
|
||||
}
|
||||
|
||||
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
outputs: outputsFromJobEntry(job),
|
||||
createTime: job.create_time,
|
||||
executionStartTime: job.execution_start_time,
|
||||
executionEndTime: job.execution_end_time,
|
||||
workflowId: job.workflow_id
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeededJob(job: GeneratedJobFixture) {
|
||||
const outputs = job.outputs.map(normalizeOutputFixture)
|
||||
const preview = outputs[0]
|
||||
const createTime =
|
||||
job.createTime ??
|
||||
(job.createdAt
|
||||
? new Date(job.createdAt).getTime()
|
||||
: DEFAULT_FIXTURE_CREATE_TIME)
|
||||
const executionStartTime = job.executionStartTime ?? createTime
|
||||
const executionEndTime = job.executionEndTime ?? createTime + 2_000
|
||||
|
||||
const listItem: JobEntry = {
|
||||
id: job.jobId,
|
||||
status: job.status ?? 'completed',
|
||||
create_time: createTime,
|
||||
execution_start_time: executionStartTime,
|
||||
execution_end_time: executionEndTime,
|
||||
preview_output: {
|
||||
filename: preview.filename,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
nodeId: job.nodeId ?? '5',
|
||||
mediaType: preview.mediaType ?? 'images',
|
||||
display_name: preview.displayName
|
||||
},
|
||||
outputs_count: outputs.length,
|
||||
...(job.workflowId ? { workflow_id: job.workflowId } : {})
|
||||
}
|
||||
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
workflow: job.workflow,
|
||||
outputs: buildMockJobOutputs(job, outputs),
|
||||
update_time: executionEndTime
|
||||
}
|
||||
|
||||
return { listItem, detail }
|
||||
}
|
||||
|
||||
export class AssetScenarioHelper {
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private generatedJobs: GeneratedJobFixture[] = []
|
||||
private importedFiles: ImportedAssetFixture[] = []
|
||||
private seededFiles = new Map<string, SeededAssetFile>()
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly jobsBackend = new InMemoryJobsBackend(page)
|
||||
) {}
|
||||
|
||||
async seedGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
|
||||
await this.seed({
|
||||
generated: jobs.map(generatedJobFromJobEntry),
|
||||
imported: this.importedFiles
|
||||
})
|
||||
}
|
||||
|
||||
async seedImportedFiles(files: readonly string[]): Promise<void> {
|
||||
await this.seed({
|
||||
generated: this.generatedJobs,
|
||||
imported: files.map((name) => ({ name }))
|
||||
})
|
||||
}
|
||||
|
||||
async seedEmptyState(): Promise<void> {
|
||||
await this.seed({ generated: [], imported: [] })
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
this.seededFiles.clear()
|
||||
|
||||
await this.jobsBackend.clear()
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.viewRouteHandler) {
|
||||
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
|
||||
this.viewRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async seed({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: GeneratedJobFixture[]
|
||||
imported: ImportedAssetFixture[]
|
||||
}): Promise<void> {
|
||||
this.generatedJobs = [...generated]
|
||||
this.importedFiles = [...imported]
|
||||
this.seededFiles = buildSeededFiles({
|
||||
generated: this.generatedJobs,
|
||||
imported: this.importedFiles
|
||||
})
|
||||
|
||||
await this.jobsBackend.seed(this.generatedJobs.map(buildSeededJob))
|
||||
await this.ensureInputFilesRoute()
|
||||
await this.ensureViewRoute()
|
||||
}
|
||||
|
||||
private async ensureInputFilesRoute(): Promise<void> {
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
private async ensureViewRoute(): Promise<void> {
|
||||
if (this.viewRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.viewRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename')
|
||||
const type = url.searchParams.get('type') ?? 'output'
|
||||
const subfolder = url.searchParams.get('subfolder') ?? ''
|
||||
|
||||
if (!filename) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Missing filename' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const seededFile =
|
||||
this.seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
})
|
||||
) ?? defaultFileFor(filename)
|
||||
|
||||
if (seededFile.filePath) {
|
||||
const body = await readFile(seededFile.filePath)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body: seededFile.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewRoutePattern, this.viewRouteHandler)
|
||||
}
|
||||
}
|
||||
251
browser_tests/fixtures/helpers/AssetsHelper.ts
Normal file
251
browser_tests/fixtures/helpers/AssetsHelper.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now()
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the POST /api/history endpoint used for deleting history items.
|
||||
* On receiving a `{ delete: [id] }` payload, removes matching jobs from
|
||||
* the in-memory mock state so subsequent /api/jobs fetches reflect the
|
||||
* deletion.
|
||||
*/
|
||||
async mockDeleteHistory(): Promise<void> {
|
||||
if (this.deleteHistoryRouteHandler) return
|
||||
|
||||
this.deleteHistoryRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const body = request.postDataJSON() as { delete?: string[] }
|
||||
if (body.delete) {
|
||||
const idsToRemove = new Set(body.delete)
|
||||
this.generatedJobs = this.generatedJobs.filter(
|
||||
(job) => !idsToRemove.has(job.id)
|
||||
)
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, body: '{}' })
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.deleteHistoryRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.deleteHistoryRouteHandler) {
|
||||
await this.page.unroute(
|
||||
historyRoutePattern,
|
||||
this.deleteHistoryRouteHandler
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
/**
|
||||
* Helper for simulating prompt execution in e2e tests.
|
||||
*/
|
||||
export class ExecutionHelper {
|
||||
private jobCounter = 0
|
||||
private readonly completedJobs: JobEntry[] = []
|
||||
private readonly completedJobs: RawJobListItem[] = []
|
||||
private readonly page: ComfyPage['page']
|
||||
private readonly command: ComfyPage['command']
|
||||
private readonly assetScenario: AssetScenarioHelper
|
||||
private readonly assets: ComfyPage['assets']
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -21,7 +20,7 @@ export class ExecutionHelper {
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.command = comfyPage.command
|
||||
this.assetScenario = new AssetScenarioHelper(comfyPage.page)
|
||||
this.assets = comfyPage.assets
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +172,8 @@ export class ExecutionHelper {
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
*
|
||||
* Requires an {@link AssetsHelper} to be passed in the constructor.
|
||||
*/
|
||||
async completeWithHistory(
|
||||
jobId: string,
|
||||
@@ -192,7 +193,7 @@ export class ExecutionHelper {
|
||||
})
|
||||
)
|
||||
|
||||
await this.assetScenario.seedGeneratedHistory(this.completedJobs)
|
||||
await this.assets.mockOutputHistory(this.completedJobs)
|
||||
this.executionSuccess(jobId)
|
||||
// Trigger queue/history refresh
|
||||
this.status(0)
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
|
||||
export type SeededJob = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
type JobsListFixtureResponse = Omit<JobsListResponse, 'pagination'> & {
|
||||
pagination: Omit<JobsListResponse['pagination'], 'limit'> & {
|
||||
limit: number | null
|
||||
}
|
||||
}
|
||||
|
||||
function parseLimit(url: URL): { error?: string; limit?: number } {
|
||||
if (!url.searchParams.has('limit')) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value)) {
|
||||
return { error: 'limit must be an integer' }
|
||||
}
|
||||
|
||||
if (value <= 0) {
|
||||
return { error: 'limit must be a positive integer' }
|
||||
}
|
||||
|
||||
return { limit: value }
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: JobEntry): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
|
||||
return end - start
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class InMemoryJobsBackend {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private seededJobs = new Map<string, SeededJob>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async seed(jobs: SeededJob[]): Promise<void> {
|
||||
this.seededJobs = new Map(
|
||||
jobs.map((job) => [job.listItem.id, job] satisfies [string, SeededJob])
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.seededJobs.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.seededJobs.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const { error: limitError, limit } = parseLimit(url)
|
||||
if (limitError) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: limitError })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs =
|
||||
limit === undefined
|
||||
? filteredJobs.slice(offset)
|
||||
: filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit: limit ?? null,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListFixtureResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.seededJobs.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.seededJobs = new Map(
|
||||
Array.from(this.seededJobs).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.seededJobs.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
export type ImportedAssetFixture = {
|
||||
name: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export type GeneratedOutputFixture = {
|
||||
filename: string
|
||||
displayName?: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
mediaType?: 'images' | 'video' | 'audio'
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
}
|
||||
|
||||
export type GeneratedJobFixture = {
|
||||
jobId: string
|
||||
status?: JobEntry['status']
|
||||
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
|
||||
createdAt?: string
|
||||
createTime?: number
|
||||
executionStartTime?: number
|
||||
executionEndTime?: number
|
||||
workflowId?: string
|
||||
workflow?: JobDetailResponse['workflow']
|
||||
nodeId?: string
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { JobDetailResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
|
||||
export function buildMockJobOutputs(
|
||||
job: GeneratedJobFixture,
|
||||
outputs: GeneratedOutputFixture[]
|
||||
): NonNullable<JobDetailResponse['outputs']> {
|
||||
const nodeId = job.nodeId ?? '5'
|
||||
const nodeOutputs: Pick<TaskOutput[string], 'audio' | 'images' | 'video'> = {}
|
||||
|
||||
for (const output of outputs) {
|
||||
const mediaType = output.mediaType ?? 'images'
|
||||
|
||||
nodeOutputs[mediaType] = [
|
||||
...(nodeOutputs[mediaType] ?? []),
|
||||
{
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder ?? '',
|
||||
type: output.type ?? 'output',
|
||||
display_name: output.displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const taskOutput = { [nodeId]: nodeOutputs } satisfies TaskOutput
|
||||
|
||||
return taskOutput
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { SeededJob } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<JobEntry>
|
||||
): JobEntry[] {
|
||||
const now = Date.now()
|
||||
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
createMockJob({
|
||||
id: `job-${String(index + 1).padStart(3, '0')}`,
|
||||
create_time: now - index * 60_000,
|
||||
execution_start_time: now - index * 60_000,
|
||||
execution_end_time: now - index * 60_000 + (5 + index) * 1_000,
|
||||
preview_output: {
|
||||
filename: `image_${String(index + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createSeededJob(listItem: JobEntry): SeededJob {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createSeededJobs(listItems: readonly JobEntry[]): SeededJob[] {
|
||||
return listItems.map(createSeededJob)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export type SeededAssetFile = {
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
textContent?: string
|
||||
}
|
||||
|
||||
export type SeededFileLocation = {
|
||||
filename: string
|
||||
type: string
|
||||
subfolder: string
|
||||
}
|
||||
|
||||
function getFixturePath(relativePath: string): string {
|
||||
return path.resolve(helperDir, '../../assets', relativePath)
|
||||
}
|
||||
|
||||
export function buildSeededFileKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}: SeededFileLocation): string {
|
||||
return new URLSearchParams({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}).toString()
|
||||
}
|
||||
|
||||
export function defaultFileFor(filename: string): SeededAssetFile {
|
||||
const normalized = filename.toLowerCase()
|
||||
|
||||
if (normalized.endsWith('.png')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
|
||||
contentType: 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webp')) {
|
||||
return {
|
||||
filePath: getFixturePath('example.webp'),
|
||||
contentType: 'image/webp'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webm')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.webm'),
|
||||
contentType: 'video/webm'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.mp4')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
|
||||
contentType: 'video/mp4'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.glb')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.glb'),
|
||||
contentType: 'model/gltf-binary'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.json')) {
|
||||
return {
|
||||
textContent: JSON.stringify({ mocked: true }, null, 2),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: 'mocked asset content',
|
||||
contentType: getMimeType(filename)
|
||||
}
|
||||
}
|
||||
|
||||
function outputLocation(output: GeneratedOutputFixture): SeededFileLocation {
|
||||
return {
|
||||
filename: output.filename,
|
||||
type: output.type ?? 'output',
|
||||
subfolder: output.subfolder ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function importedAssetLocation(
|
||||
asset: ImportedAssetFixture
|
||||
): SeededFileLocation {
|
||||
return {
|
||||
filename: asset.name,
|
||||
type: 'input',
|
||||
subfolder: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSeededFiles({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: readonly GeneratedJobFixture[]
|
||||
imported: readonly ImportedAssetFixture[]
|
||||
}): Map<string, SeededAssetFile> {
|
||||
const seededFiles = new Map<string, SeededAssetFile>()
|
||||
|
||||
for (const job of generated) {
|
||||
for (const output of job.outputs) {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
seededFiles.set(buildSeededFileKey(outputLocation(output)), {
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of imported) {
|
||||
const fallback = defaultFileFor(asset.name)
|
||||
|
||||
seededFiles.set(buildSeededFileKey(importedAssetLocation(asset)), {
|
||||
filePath: asset.filePath ?? fallback.filePath,
|
||||
contentType: asset.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
|
||||
return seededFiles
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
|
||||
|
||||
export const jobsBackendFixture = base.extend<{
|
||||
jobsBackend: InMemoryJobsBackend
|
||||
}>({
|
||||
jobsBackend: async ({ page }, use) => {
|
||||
const jobsBackend = new InMemoryJobsBackend(page)
|
||||
|
||||
await use(jobsBackend)
|
||||
|
||||
await jobsBackend.clear()
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
@@ -18,8 +17,6 @@ import {
|
||||
STABLE_OUTPUT
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
test.describe('AssetHelper', () => {
|
||||
test.describe('operators and configuration', () => {
|
||||
test('creates helper with models via withModels operator', async ({
|
||||
@@ -69,7 +66,8 @@ test.describe('AssetHelper', () => {
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
@@ -89,12 +87,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(2)
|
||||
expect(data.has_more).toBe(false)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets respects pagination params', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('GET /assets respects pagination params', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
@@ -112,12 +110,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(10)
|
||||
expect(data.has_more).toBe(true)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets filters by include_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
@@ -131,12 +129,14 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets).toHaveLength(1)
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
comfyPage
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
|
||||
`${comfyPage.url}/api/assets/nonexistent-id`
|
||||
)
|
||||
expect(notFound.status).toBe(404)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('PUT /assets/:id updates asset in store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -175,12 +175,14 @@ test.describe('AssetHelper', () => {
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
|
||||
'renamed.safetensors'
|
||||
)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('DELETE /assets/:id removes asset from store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
comfyPage
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -191,12 +193,11 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(204)
|
||||
expect(assetApi.assetCount).toBe(1)
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets returns upload response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('POST /assets returns upload response', async ({ comfyPage }) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
@@ -204,6 +205,7 @@ test.describe('AssetHelper', () => {
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -215,12 +217,14 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { id: string; name: string }
|
||||
expect(data.id).toBe('custom-upload-001')
|
||||
expect(data.name).toBe('custom.safetensors')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets/download returns async download response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
comfyPage
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -231,14 +235,14 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { task_id: string; status: string }
|
||||
expect(data.task_id).toBe('download-task-001')
|
||||
expect(data.status).toBe('created')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mutation tracking', () => {
|
||||
test('tracks POST, PUT, DELETE mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -261,12 +265,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(mutations[0].method).toBe('POST')
|
||||
expect(mutations[1].method).toBe('PUT')
|
||||
expect(mutations[2].method).toBe('DELETE')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET requests are not tracked as mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -276,14 +280,14 @@ test.describe('AssetHelper', () => {
|
||||
)
|
||||
|
||||
expect(assetApi.getMutations()).toHaveLength(0)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mockError', () => {
|
||||
test('returns error status for all asset routes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
test('returns error status for all asset routes', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -292,14 +296,16 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(503)
|
||||
const data = body as { error: string }
|
||||
expect(data.error).toBe('Service Unavailable')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('clearMocks', () => {
|
||||
test('resets store, mutations, and unroutes handlers', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
comfyPage
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsBackendFixture } from '@e2e/fixtures/jobsBackendFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createSeededJobs
|
||||
} from '@e2e/fixtures/helpers/jobFixtures'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsBackendFixture)
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
@@ -41,14 +35,16 @@ const MOCK_JOBS: JobEntry[] = [
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsBackend }) => {
|
||||
await jobsBackend.seed(createSeededJobs(MOCK_JOBS))
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Queue.QPOV2': false
|
||||
})
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '@e2e/fixtures/helpers/jobFixtures'
|
||||
} from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: JobEntry[] = [
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1_000_000,
|
||||
execution_start_time: 1_000_000,
|
||||
execution_end_time: 1_010_000,
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
@@ -27,9 +28,9 @@ const SAMPLE_JOBS: JobEntry[] = [
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2_000_000,
|
||||
execution_start_time: 2_000_000,
|
||||
execution_end_time: 2_003_000,
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
@@ -41,9 +42,9 @@ const SAMPLE_JOBS: JobEntry[] = [
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3_000_000,
|
||||
execution_start_time: 3_000_000,
|
||||
execution_end_time: 3_020_000,
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
@@ -61,12 +62,20 @@ const SAMPLE_IMPORTED_FILES = [
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedEmptyState()
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -92,13 +101,21 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles(SAMPLE_IMPORTED_FILES)
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -113,10 +130,12 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -125,21 +144,31 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles(SAMPLE_IMPORTED_FILES)
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -164,8 +193,8 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays svg outputs', async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory([
|
||||
test('Displays svg outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
@@ -189,22 +218,31 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -213,10 +251,12 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
@@ -225,13 +265,21 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -248,6 +296,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
@@ -261,6 +310,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -278,20 +328,30 @@ test.describe('Assets sidebar - search', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -303,9 +363,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -317,6 +379,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
@@ -328,12 +391,15 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -343,31 +409,44 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -460,17 +539,23 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -479,17 +564,26 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -521,14 +615,17 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
@@ -536,11 +633,82 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - delete confirmation', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('initial load fetches first batch with offset 0', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const manyJobs = createMockJobs(250)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
const status = url.searchParams.get('status') ?? ''
|
||||
return status.includes('completed')
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const req = await firstRequest
|
||||
const url = new URL(req.url())
|
||||
expect(url.searchParams.get('offset')).toBe('0')
|
||||
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 11. Delete confirmation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - delete confirmation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockDeleteHistory()
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-click delete shows confirmation dialog', async ({
|
||||
@@ -576,7 +744,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
await comfyPage.confirmDialog.delete.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount - 1)
|
||||
@@ -598,54 +766,9 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.click('reject')
|
||||
await comfyPage.confirmDialog.reject.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test('initial load fetches first batch with offset 0', async ({
|
||||
comfyPage,
|
||||
assetScenario
|
||||
}) => {
|
||||
const manyJobs = createMockJobs(250)
|
||||
await assetScenario.seedGeneratedHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
const status = url.searchParams.get('status') ?? ''
|
||||
return status.includes('completed')
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const req = await firstRequest
|
||||
const url = new URL(req.url())
|
||||
expect(url.searchParams.get('offset')).toBe('0')
|
||||
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
|
||||
const GENERATED_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1_000_000,
|
||||
execution_start_time: 1_000_000,
|
||||
execution_end_time: 1_010_000,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2_000_000,
|
||||
execution_start_time: 2_000_000,
|
||||
execution_end_time: 2_008_000,
|
||||
preview_output: {
|
||||
filename: 'portrait.webp',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3_000_000,
|
||||
execution_start_time: 3_000_000,
|
||||
execution_end_time: 3_015_000,
|
||||
preview_output: {
|
||||
filename: 'gallery.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Assets sidebar actions', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(GENERATED_JOBS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('shows selection footer actions after selecting an asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.asset('gallery.png').click()
|
||||
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('supports multi-select and deselect all', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.selectAssets(['landscape.png', 'portrait.webp'])
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows the output asset context menu actions', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openContextMenuForAsset('landscape.png')
|
||||
|
||||
await expect(tab.contextMenuAction('Download')).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Inspect asset')).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Delete')).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the bulk context menu for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.selectAssets(['landscape.png', 'portrait.webp'])
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
await tab.asset('landscape.png').dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
|
||||
await expect(tab.contextMenuAction('Download all')).toBeVisible()
|
||||
})
|
||||
|
||||
test('confirms delete and removes the selected asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
await tab.runContextMenuAction('gallery.png', 'Delete')
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.confirmDialog.root.getByText('Delete this asset?')
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeHidden()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount - 1)
|
||||
await expect(
|
||||
comfyPage.page.locator('.p-toast-message-success')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
|
||||
const GENERATED_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-landscape',
|
||||
create_time: 1_000_000,
|
||||
execution_start_time: 1_000_000,
|
||||
execution_end_time: 1_010_000,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-portrait',
|
||||
create_time: 2_000_000,
|
||||
execution_start_time: 2_000_000,
|
||||
execution_end_time: 2_008_000,
|
||||
preview_output: {
|
||||
filename: 'portrait.webp',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gallery',
|
||||
create_time: 3_000_000,
|
||||
execution_start_time: 3_000_000,
|
||||
execution_end_time: 3_015_000,
|
||||
preview_output: {
|
||||
filename: 'gallery.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 3
|
||||
})
|
||||
]
|
||||
|
||||
const IMPORTED_FILES = ['reference_photo.png', 'background.jpg', 'notes.txt']
|
||||
|
||||
test.describe('Assets sidebar browsing', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(GENERATED_JOBS)
|
||||
await assetScenario.seedImportedFiles(IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('shows seeded generated and imported assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
|
||||
|
||||
await tab.switchToImported()
|
||||
await expect(tab.getAssetCardByName('reference_photo.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('switches between grid and list views with seeded results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.getAssetCardByName('landscape.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clears search when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
|
||||
test('opens folder view for multi-output jobs and returns to all assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab
|
||||
.getAssetCardByName('gallery.png')
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Copy job ID' })
|
||||
).toBeVisible()
|
||||
await expect(tab.getAssetCardByName('gallery-2.png')).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: 'Copy job ID' }).click()
|
||||
await expect(
|
||||
comfyPage.page.locator('.p-toast-message-success')
|
||||
).toBeVisible()
|
||||
|
||||
await tab.backToAssetsButton.click()
|
||||
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens the preview lightbox for generated assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.getAssetCardByName('landscape.png').dblclick()
|
||||
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedEmptyState()
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('shows empty generated state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows empty imported state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
|
||||
const HISTORY_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: 1_000_000,
|
||||
execution_start_time: 1_000_000,
|
||||
execution_end_time: 1_010_000,
|
||||
preview_output: {
|
||||
filename: 'history-completed.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: 2_000_000,
|
||||
execution_start_time: 2_000_000,
|
||||
execution_end_time: 2_005_000,
|
||||
preview_output: {
|
||||
filename: 'history-failed.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
})
|
||||
]
|
||||
|
||||
async function openOverlayMenu(comfyPage: {
|
||||
page: {
|
||||
getByTestId(id: string): Locator
|
||||
getByLabel(label: string | RegExp): Locator
|
||||
}
|
||||
}) {
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
await comfyPage.page
|
||||
.getByLabel(/More options/i)
|
||||
.first()
|
||||
.click()
|
||||
}
|
||||
|
||||
test.describe('Job history sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(HISTORY_JOBS)
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Queue.QPOV2': true
|
||||
})
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('shows seeded history and filters failed jobs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.jobHistoryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.jobRow('job-completed-1')).toBeVisible()
|
||||
await expect(tab.jobRow('job-failed-1')).toBeVisible()
|
||||
|
||||
await tab.failedTab.click()
|
||||
|
||||
await expect(tab.jobRow('job-failed-1')).toBeVisible()
|
||||
await expect(tab.jobRow('job-completed-1')).toBeHidden()
|
||||
})
|
||||
|
||||
test('opens the preview lightbox for completed jobs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.jobHistoryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.jobRow('job-completed-1').dblclick()
|
||||
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('clears history from the docked sidebar', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.jobHistoryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.moreOptionsButton.click()
|
||||
await comfyPage.page.getByTestId('clear-history-action').click()
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
await comfyPage.confirmDialog.root
|
||||
.getByRole('button', { name: 'Clear' })
|
||||
.click()
|
||||
|
||||
await expect(tab.jobRows).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('disables clear queue when there are no queued jobs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.jobHistoryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.clearQueuedButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Floating overlay dock to job history', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(HISTORY_JOBS)
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Queue.QPOV2': false
|
||||
})
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('opens the docked job history sidebar from the floating overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openOverlayMenu(comfyPage)
|
||||
await comfyPage.page.getByTestId('docked-job-history-action').click()
|
||||
|
||||
await expect(comfyPage.menu.jobHistoryTab.searchInput).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.menu.jobHistoryTab.jobRow('job-completed-1')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
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
@@ -14,7 +14,6 @@
|
||||
<button
|
||||
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
|
||||
role="button"
|
||||
:aria-label="$t('mediaAsset.actions.copyJobId')"
|
||||
@click="copyJobId"
|
||||
>
|
||||
<i class="icon-[lucide--copy] text-sm"></i>
|
||||
@@ -119,8 +118,6 @@
|
||||
<div
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
role="toolbar"
|
||||
:aria-label="$t('mediaAsset.selection.actions')"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
@@ -146,7 +143,6 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
@@ -154,7 +150,6 @@
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3085,7 +3085,6 @@
|
||||
"selection": {
|
||||
"selectedCount": "Assets Selected: {count}",
|
||||
"multipleSelectedAssets": "Multiple assets selected",
|
||||
"actions": "Selected asset actions",
|
||||
"deselectAll": "Deselect all",
|
||||
"downloadSelected": "Download",
|
||||
"downloadSelectedAll": "Download all",
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
variant="secondary"
|
||||
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
|
||||
@click.stop="handleOutputCountClick"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
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