Compare commits
48 Commits
v1.46.8
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2ddf525a | ||
|
|
bc98f71c2d | ||
|
|
1b90696459 | ||
|
|
e340b5b127 | ||
|
|
68238a5742 | ||
|
|
183a75a218 | ||
|
|
507e667795 | ||
|
|
d617ab1c95 | ||
|
|
b93338cac7 | ||
|
|
fa2d187c83 | ||
|
|
6f22ca11c3 | ||
|
|
c17f91095a | ||
|
|
b67d77f08a | ||
|
|
d7f0d75efd | ||
|
|
74b9f16b62 | ||
|
|
dbeb9cc10d | ||
|
|
49a05f32ca | ||
|
|
f8187cec4c | ||
|
|
7e61358724 | ||
|
|
ff9e6415b5 | ||
|
|
c695aa1ee0 | ||
|
|
b907423526 | ||
|
|
7d99189211 | ||
|
|
5ddf5faef3 | ||
|
|
4fe78997b0 | ||
|
|
cda2929572 | ||
|
|
82bea29dda | ||
|
|
d129f757c0 | ||
|
|
874b486640 | ||
|
|
1eede54582 | ||
|
|
be131f7e9a | ||
|
|
daf07a7442 | ||
|
|
454e124099 | ||
|
|
8a819fa2be | ||
|
|
35157f1af0 | ||
|
|
f9cbaf750f | ||
|
|
4bfb0c36be | ||
|
|
b996ed3ab5 | ||
|
|
dc46519fa7 | ||
|
|
28837b8913 | ||
|
|
6d495f5642 | ||
|
|
f86ffbb05f | ||
|
|
6e7c4f85fe | ||
|
|
11a621152c | ||
|
|
6fe50a3685 | ||
|
|
488bc33288 | ||
|
|
f0034b9b1b | ||
|
|
e4d5824813 |
2
.github/workflows/pr-report.yaml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
|
||||
types:
|
||||
- completed
|
||||
branches-ignore:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -4,6 +4,10 @@ import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const LINUX_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
|
||||
test.describe('Download page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -38,7 +42,7 @@ test.describe('Download page @smoke', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
|
||||
@@ -52,6 +56,61 @@ test.describe('Download page @smoke', () => {
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({ userAgent: LINUX_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
const macBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
|
||||
)
|
||||
await expect(macBtn).toBeVisible()
|
||||
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toHaveCount(2)
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
|
||||
const context = await browser.newContext({ userAgent: IPHONE_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
).toBeVisible()
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
@@ -176,7 +235,7 @@ test.describe('Download page mobile @mobile', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
|
||||
@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
|
||||
has: page.getByRole('heading', { name: 'Get started in minutes' })
|
||||
})
|
||||
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Local' })
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
|
||||
await expect(downloadLink).toBeVisible()
|
||||
await expect(downloadLink).toHaveAttribute('href', '/download')
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
|
||||
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Local',
|
||||
'Comfy Desktop',
|
||||
'Comfy Cloud',
|
||||
'Comfy API',
|
||||
'Comfy Enterprise'
|
||||
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.locator('main').hover()
|
||||
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
|
||||
await expect(menu.getByText('Comfy Local')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
17
apps/website/src/components/common/Badge.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { BadgeVariants } from './badge.variants'
|
||||
import { badgeVariants } from './badge.variants'
|
||||
|
||||
const { variant, class: className } = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant }), className)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
53
apps/website/src/components/common/CallToActionSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
primaryLabelKey,
|
||||
primaryHref,
|
||||
secondaryLabelKey,
|
||||
secondaryHref
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
primaryLabelKey: TranslationKey
|
||||
primaryHref?: string
|
||||
secondaryLabelKey?: TranslationKey
|
||||
secondaryHref?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<BrandButton
|
||||
:href="primaryHref"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(primaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryLabelKey"
|
||||
:href="secondaryHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(secondaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
102
apps/website/src/components/common/EventsSection.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
export type EventItem = {
|
||||
label: LocalizedText
|
||||
title: LocalizedText
|
||||
cta: LocalizedText
|
||||
href: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
descriptionKey,
|
||||
notifyLabelKey,
|
||||
notifyHref,
|
||||
events
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
notifyLabelKey: TranslationKey
|
||||
notifyHref?: string
|
||||
events: readonly EventItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-12">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
<div>
|
||||
<BrandButton
|
||||
:href="notifyHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(notifyLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray flex-1 text-sm">
|
||||
{{ event.title[locale] }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
{{ event.cta[locale] }}
|
||||
<svg
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
165
apps/website/src/components/common/MaskRevealButton.stories.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MaskRevealButton from './MaskRevealButton.vue'
|
||||
|
||||
const meta: Meta<typeof MaskRevealButton> = {
|
||||
title: 'Website/Common/MaskRevealButton',
|
||||
component: MaskRevealButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['right', 'left']
|
||||
},
|
||||
hideLabel: { control: 'boolean' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<MaskRevealButton v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LabelVisible: Story = {
|
||||
args: { href: '#', hideLabel: false },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template:
|
||||
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MaskRevealButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
82
apps/website/src/components/common/MaskRevealButton.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
|
||||
import {
|
||||
maskRevealButtonBadgeVariants,
|
||||
maskRevealButtonVariants,
|
||||
maskRevealLabelVariants
|
||||
} from './maskRevealButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
variant?: MaskRevealButtonVariants['variant']
|
||||
size?: MaskRevealButtonVariants['size']
|
||||
iconPosition?: MaskRevealButtonVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href="href || undefined"
|
||||
:target="href ? target : undefined"
|
||||
:rel="href ? rel : undefined"
|
||||
:type="!href ? type : undefined"
|
||||
:disabled="!href ? disabled : undefined"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:data-icon-position="iconPosition ?? 'right'"
|
||||
:data-hidden="hideLabel ? 'true' : 'false'"
|
||||
:class="maskRevealLabelVariants()"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
165
apps/website/src/components/common/PillButton.stories.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import PillButton from './PillButton.vue'
|
||||
|
||||
const meta: Meta<typeof PillButton> = {
|
||||
title: 'Website/Common/PillButton',
|
||||
component: PillButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['right', 'left']
|
||||
},
|
||||
hideLabel: { control: 'boolean' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AsAnchor: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const AsButton: Story = {
|
||||
args: { type: 'button' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Submit</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Read More</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<PillButton v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</PillButton>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Go Back</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const RevealLabelOnHover: Story = {
|
||||
args: { href: '#', hideLabel: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Unavailable</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { PillButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
84
apps/website/src/components/common/PillButton.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { PillButtonVariants } from './pillButton.variants'
|
||||
import {
|
||||
pillButtonBadgeVariants,
|
||||
pillButtonVariants
|
||||
} from './pillButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = false,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
variant?: PillButtonVariants['variant']
|
||||
size?: PillButtonVariants['size']
|
||||
iconPosition?: PillButtonVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href="href || undefined"
|
||||
:target="href ? target : undefined"
|
||||
:rel="href ? rel : undefined"
|
||||
:type="!href ? type : undefined"
|
||||
:disabled="!href ? disabled : undefined"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'relative leading-none transition-all duration-500',
|
||||
hideLabel && 'opacity-0 group-hover:opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
const {
|
||||
logoSrc = '/icons/logo.svg',
|
||||
logoAlt = 'Comfy',
|
||||
text = 'LOCAL'
|
||||
text = 'DESKTOP'
|
||||
} = defineProps<{
|
||||
logoSrc?: string
|
||||
logoAlt?: string
|
||||
@@ -20,7 +20,7 @@ const {
|
||||
/>
|
||||
|
||||
<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"
|
||||
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
|
||||
>
|
||||
<img
|
||||
:src="logoSrc"
|
||||
@@ -37,7 +37,7 @@ const {
|
||||
/>
|
||||
|
||||
<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"
|
||||
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
|
||||
>
|
||||
<span
|
||||
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
|
||||
|
||||
@@ -43,6 +43,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -52,6 +52,7 @@ const navLinks: NavLink[] = [
|
||||
{
|
||||
label: t('nav.resources', locale),
|
||||
items: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
@@ -93,7 +94,7 @@ const ctaButtons = [
|
||||
{
|
||||
label: t('nav.downloadLocal', locale),
|
||||
prefix: 'DOWNLOAD',
|
||||
core: 'LOCAL',
|
||||
core: 'DESKTOP',
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
@@ -163,7 +164,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
17
apps/website/src/components/common/badge.variants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
110
apps/website/src/components/common/maskRevealButton.variants.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const maskRevealButtonVariants = cva({
|
||||
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 text-xs',
|
||||
md: 'h-12 text-sm',
|
||||
lg: 'h-14 text-base'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
|
||||
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
|
||||
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
|
||||
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
|
||||
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
|
||||
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const maskRevealButtonBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
md: 'size-10',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const maskRevealLabelVariants = cva({
|
||||
base: [
|
||||
'relative inline-block align-baseline',
|
||||
'[will-change:mask-size,-webkit-mask-size]',
|
||||
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
|
||||
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
|
||||
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
|
||||
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
|
||||
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
|
||||
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
|
||||
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
|
||||
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
|
||||
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
|
||||
].join(' ')
|
||||
})
|
||||
|
||||
export type MaskRevealButtonVariants = VariantProps<
|
||||
typeof maskRevealButtonVariants
|
||||
>
|
||||
116
apps/website/src/components/common/pillButton.variants.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const pillButtonVariants = cva({
|
||||
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 text-xs',
|
||||
md: 'h-12 text-sm',
|
||||
lg: 'h-14 text-base'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const pillButtonBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
md: 'size-10',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const tags = ['Seadance 2.0', 'Image To Video']
|
||||
const demoVideoSrc =
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
|
||||
const demoVideoPoster =
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.featured.title', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
|
||||
{{ t('learning.featured.author', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('learning.featured.description', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BrandButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<ul class="mt-2 flex flex-wrap gap-3">
|
||||
<li v-for="tag in tags" :key="tag">
|
||||
<Badge variant="subtle">{{ tag }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
|
||||
<VideoPlayer
|
||||
:locale
|
||||
:src="demoVideoSrc"
|
||||
:poster="demoVideoPoster"
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
23
apps/website/src/components/learning/HeroSection.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
>{{ t('learning.heroTitle.after', locale) }}
|
||||
<br />
|
||||
{{ t('learning.heroTitle.line2', locale) }}
|
||||
</h1>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
|
||||
const playFromStart = () => {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tutorial.id,
|
||||
() => {
|
||||
playFromStart()
|
||||
}
|
||||
)
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) emit('close')
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lockScroll()
|
||||
dialogRef.value?.showModal()
|
||||
playFromStart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlockScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
:aria-label="tutorial.title[locale]"
|
||||
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
|
||||
@click="handleBackdropClick"
|
||||
@keydown="handleKeydown"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h2>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
120
apps/website/src/components/learning/TutorialsSection.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
v-if="tutorial.href"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -3,7 +3,10 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
|
||||
import {
|
||||
downloadUrls,
|
||||
useDownloadUrl
|
||||
} from '../../../composables/useDownloadUrl'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
|
||||
@@ -12,32 +15,64 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { downloadUrl, platform } = useDownloadUrl()
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'mac':
|
||||
return '/icons/os/apple.svg'
|
||||
case 'windows':
|
||||
return '/icons/os/windows.svg'
|
||||
default:
|
||||
return undefined
|
||||
const ICONS = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const buttons = computed<ButtonSpec[]>(() => {
|
||||
if (platform.value) {
|
||||
return [
|
||||
{
|
||||
key: platform.value,
|
||||
href: downloadUrl.value,
|
||||
icon: ICONS[platform.value]
|
||||
}
|
||||
]
|
||||
}
|
||||
if (showFallback.value) {
|
||||
const label = t('download.hero.downloadLocal', locale)
|
||||
return [
|
||||
{
|
||||
key: 'windows',
|
||||
href: downloadUrls.windows,
|
||||
icon: ICONS.windows,
|
||||
ariaLabel: `${label} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BrandButton
|
||||
v-show="platform"
|
||||
:href="downloadUrl"
|
||||
v-for="btn in buttons"
|
||||
:key="btn.key"
|
||||
:href="btn.href"
|
||||
target="_blank"
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<img
|
||||
v-if="iconSrc"
|
||||
:src="iconSrc"
|
||||
:src="btn.icon"
|
||||
alt=""
|
||||
class="ppformula-text-center size-5 -translate-y-0.75"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -283,16 +283,16 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
|
||||
>
|
||||
<ProductHeroBadge />
|
||||
<ProductHeroBadge text="DESKTOP" />
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
|
||||
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('download.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
|
||||
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
|
||||
>
|
||||
{{ t('download.hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
const downloadUrls = {
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
@@ -24,6 +24,8 @@ function detectPlatform(ua: string): DetectedPlatform {
|
||||
// When Linux and/or macIntel builds are added, extend detection and URLs here.
|
||||
export function useDownloadUrl() {
|
||||
const platform = ref<DetectedPlatform>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
if (platform.value === 'windows') return downloadUrls.windows
|
||||
@@ -31,9 +33,16 @@ export function useDownloadUrl() {
|
||||
return externalLinks.github
|
||||
})
|
||||
|
||||
const showFallback = computed(
|
||||
() => detected.value && !platform.value && !isMobileUa.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
isMobileUa.value = isMobile(ua)
|
||||
platform.value = detectPlatform(ua)
|
||||
detected.value = true
|
||||
})
|
||||
|
||||
return { downloadUrl, platform }
|
||||
return { downloadUrl, platform, showFallback }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const baseRoutes = {
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
|
||||
31
apps/website/src/data/events.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { EventItem } from '../components/common/EventsSection.vue'
|
||||
|
||||
export const learningEvents: readonly EventItem[] = [
|
||||
{
|
||||
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
|
||||
title: {
|
||||
en: 'Zero to Node: Building Your First Workflow',
|
||||
'zh-CN': '从零到节点:构建你的第一个工作流'
|
||||
},
|
||||
cta: { en: 'Link', 'zh-CN': '链接' },
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
label: { en: 'Event 1', 'zh-CN': '活动 1' },
|
||||
title: {
|
||||
en: 'Lorem ipsum dollar sita met',
|
||||
'zh-CN': '此处为活动描述的占位文本'
|
||||
},
|
||||
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
label: { en: 'Event 2', 'zh-CN': '活动 2' },
|
||||
title: {
|
||||
en: 'Lorem ipsum dollar sita met',
|
||||
'zh-CN': '此处为活动描述的占位文本'
|
||||
},
|
||||
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
|
||||
href: '#'
|
||||
}
|
||||
] as const
|
||||
84
apps/website/src/data/learningTutorials.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
export const learningTutorials: readonly LearningTutorial[] = [
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
// href: '#',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'deaging_workflow_v03',
|
||||
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'frame_adjustments_demo_v03',
|
||||
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'mattes_and_utilities_v03',
|
||||
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'seedance_demo_comfyui_v03',
|
||||
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'skyreplacement_smaller_v06',
|
||||
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
@@ -1,6 +1,22 @@
|
||||
type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const translations = {
|
||||
// Tags (global, reusable across sections)
|
||||
'tags.partnerNodes': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'tags.imageToVideo': {
|
||||
en: 'Image To Video',
|
||||
'zh-CN': '图像生成视频'
|
||||
},
|
||||
|
||||
// CTAs (global, reusable across sections)
|
||||
'cta.tryWorkflow': {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
@@ -108,8 +124,8 @@ const translations = {
|
||||
'zh-CN': '下载或注册'
|
||||
},
|
||||
'getStarted.step1.downloadLocal': {
|
||||
en: 'Download Local',
|
||||
'zh-CN': '下载本地版'
|
||||
en: 'Download Desktop',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'getStarted.step1.launchCloud': {
|
||||
en: 'Launch Cloud',
|
||||
@@ -589,8 +605,8 @@ const translations = {
|
||||
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
|
||||
},
|
||||
'download.faq.4.q': {
|
||||
en: 'Why would I pay for Comfy Cloud if Local is free?',
|
||||
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud?'
|
||||
en: 'Why would I pay for Comfy Cloud if Desktop 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.',
|
||||
@@ -607,8 +623,8 @@ const translations = {
|
||||
'Desktop:一键安装,自动更新。Portable:独立构建,可从任意文件夹运行。CLI:从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
|
||||
},
|
||||
'download.faq.6.q': {
|
||||
en: 'Can I use my local workflows in Comfy Cloud?',
|
||||
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
|
||||
en: 'Can I use my Desktop workflows in Comfy Cloud?',
|
||||
'zh-CN': '我可以在 Comfy Cloud 中使用桌面工作流吗?'
|
||||
},
|
||||
'download.faq.6.a': {
|
||||
en: 'Yes — same file, same results. No conversion, no rework.',
|
||||
@@ -649,8 +665,8 @@ const translations = {
|
||||
'zh-CN': '专业人士为何\n选择'
|
||||
},
|
||||
'download.reason.headingHighlight': {
|
||||
en: 'Local',
|
||||
'zh-CN': '本地版'
|
||||
en: 'Desktop',
|
||||
'zh-CN': '桌面版'
|
||||
},
|
||||
'download.reason.1.title': {
|
||||
en: 'Unlimited\nCustomization',
|
||||
@@ -699,8 +715,8 @@ const translations = {
|
||||
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
|
||||
},
|
||||
'download.hero.downloadLocal': {
|
||||
en: 'DOWNLOAD LOCAL',
|
||||
'zh-CN': '下载本地版'
|
||||
en: 'DOWNLOAD DESKTOP',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'download.hero.installGithub': {
|
||||
en: 'INSTALL FROM GITHUB',
|
||||
@@ -1435,6 +1451,62 @@ const translations = {
|
||||
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
|
||||
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
|
||||
|
||||
// LearningHeroSection
|
||||
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
|
||||
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
|
||||
'learning.heroTitle.line2': {
|
||||
en: 'Build what doesn’t exist yet.',
|
||||
'zh-CN': '构建尚未存在之物。'
|
||||
},
|
||||
|
||||
// LearningFeaturedWorkflowSection
|
||||
'learning.featured.title': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
},
|
||||
'learning.featured.author': {
|
||||
en: 'by Doug Hogan',
|
||||
'zh-CN': '作者:Doug Hogan'
|
||||
},
|
||||
'learning.featured.description': {
|
||||
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
|
||||
'zh-CN':
|
||||
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
|
||||
},
|
||||
'learning.featured.watchDemo': {
|
||||
en: 'Watch Demo',
|
||||
'zh-CN': '观看演示'
|
||||
},
|
||||
|
||||
// LearningTutorialsSection
|
||||
'learning.tutorials.heading': {
|
||||
en: 'Featured Demos',
|
||||
'zh-CN': '精选演示'
|
||||
},
|
||||
'learning.tutorials.titlePrefix': {
|
||||
en: 'Learn how to:',
|
||||
'zh-CN': '学习如何:'
|
||||
},
|
||||
|
||||
// LearningCallToActionSection
|
||||
'learning.cta.heading': {
|
||||
en: 'Schedule a demo and see how ComfyUI fits your team’s creative needs.',
|
||||
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
|
||||
},
|
||||
'learning.cta.contactSales': {
|
||||
en: 'Contact Sales',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// LearningEventsSection
|
||||
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
|
||||
'learning.events.description': {
|
||||
en: 'Check out our upcoming live streams and community meetings. We’re always open to your questions, ideas, and conversations.',
|
||||
'zh-CN':
|
||||
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
|
||||
},
|
||||
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
|
||||
|
||||
// GalleryHeroSection
|
||||
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'gallery.heroTitle.before': {
|
||||
@@ -1471,9 +1543,13 @@ const translations = {
|
||||
},
|
||||
'about.hero.body': {
|
||||
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
|
||||
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
|
||||
'zh-CN':
|
||||
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
|
||||
},
|
||||
'about.hero.cta': {
|
||||
en: 'SEE OPEN ROLES',
|
||||
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
|
||||
},
|
||||
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
|
||||
|
||||
// AboutStorySection
|
||||
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
|
||||
@@ -1734,7 +1810,7 @@ const translations = {
|
||||
'nav.community': { en: 'Community', 'zh-CN': '社区' },
|
||||
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
'nav.company': { en: 'Company', 'zh-CN': '公司' },
|
||||
'nav.comfyLocal': { en: 'Comfy Local', 'zh-CN': 'Comfy 本地版' },
|
||||
'nav.comfyLocal': { en: 'Comfy Desktop', 'zh-CN': 'Comfy 桌面版' },
|
||||
'nav.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'nav.comfyApi': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
'nav.comfyEnterprise': {
|
||||
@@ -1743,6 +1819,7 @@ const translations = {
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
|
||||
@@ -1751,7 +1828,7 @@ const translations = {
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD LOCAL', 'zh-CN': '下载本地版' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
@@ -1802,8 +1879,9 @@ const translations = {
|
||||
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
|
||||
},
|
||||
'privacy.intro.block.3': {
|
||||
en: 'This policy is effective as of April 18, 2025.',
|
||||
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
|
||||
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
|
||||
'zh-CN':
|
||||
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop(本地安装应用程序)的具体信息,包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
|
||||
},
|
||||
'privacy.information-we-collect.label': {
|
||||
en: 'INFORMATION',
|
||||
@@ -2053,6 +2131,181 @@ const translations = {
|
||||
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
|
||||
},
|
||||
|
||||
// ── Desktop Privacy Policy ────────────────────────────────────────
|
||||
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
|
||||
'desktop_privacy.intro.block.0': {
|
||||
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
|
||||
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
|
||||
},
|
||||
'desktop_privacy.intro.block.1': {
|
||||
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
|
||||
'zh-CN':
|
||||
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
|
||||
},
|
||||
|
||||
'desktop_privacy.controller.label': {
|
||||
en: 'CONTROLLER',
|
||||
'zh-CN': 'CONTROLLER'
|
||||
},
|
||||
'desktop_privacy.controller.title': {
|
||||
en: 'Controller',
|
||||
'zh-CN': 'Controller'
|
||||
},
|
||||
'desktop_privacy.controller.block.0': {
|
||||
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
'desktop_privacy.data.label': {
|
||||
en: 'DATA WE PROCESS',
|
||||
'zh-CN': 'DATA WE PROCESS'
|
||||
},
|
||||
'desktop_privacy.data.title': {
|
||||
en: 'Personal data we process',
|
||||
'zh-CN': 'Personal data we process'
|
||||
},
|
||||
'desktop_privacy.data.block.0': {
|
||||
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
|
||||
'zh-CN':
|
||||
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
|
||||
},
|
||||
'desktop_privacy.data.block.1': {
|
||||
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
|
||||
'zh-CN':
|
||||
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
|
||||
},
|
||||
'desktop_privacy.data.block.2': {
|
||||
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
|
||||
'zh-CN':
|
||||
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
|
||||
},
|
||||
'desktop_privacy.data.block.3': {
|
||||
en: 'We do not process:',
|
||||
'zh-CN': 'We do not process:'
|
||||
},
|
||||
'desktop_privacy.data.block.4': {
|
||||
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
|
||||
'zh-CN':
|
||||
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
|
||||
},
|
||||
'desktop_privacy.data.block.5': {
|
||||
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
|
||||
'zh-CN':
|
||||
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
|
||||
},
|
||||
|
||||
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
|
||||
'desktop_privacy.purposes.title': {
|
||||
en: 'Purposes and lawful bases',
|
||||
'zh-CN': 'Purposes and lawful bases'
|
||||
},
|
||||
'desktop_privacy.purposes.block.0': {
|
||||
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
|
||||
'zh-CN':
|
||||
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
|
||||
},
|
||||
'desktop_privacy.purposes.block.1': {
|
||||
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
|
||||
'zh-CN':
|
||||
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
|
||||
},
|
||||
'desktop_privacy.purposes.block.2': {
|
||||
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
'desktop_privacy.purposes.block.3': {
|
||||
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
|
||||
},
|
||||
|
||||
'desktop_privacy.processors.label': {
|
||||
en: 'RECIPIENTS',
|
||||
'zh-CN': 'RECIPIENTS'
|
||||
},
|
||||
'desktop_privacy.processors.title': {
|
||||
en: 'Recipients',
|
||||
'zh-CN': 'Recipients'
|
||||
},
|
||||
'desktop_privacy.processors.block.0': {
|
||||
en: 'We engage the following processors under Data Processing Agreements:',
|
||||
'zh-CN':
|
||||
'We engage the following processors under Data Processing Agreements:'
|
||||
},
|
||||
'desktop_privacy.processors.block.1': {
|
||||
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
|
||||
'zh-CN':
|
||||
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
|
||||
},
|
||||
|
||||
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
|
||||
'desktop_privacy.transfers.title': {
|
||||
en: 'International transfers',
|
||||
'zh-CN': 'International transfers'
|
||||
},
|
||||
'desktop_privacy.transfers.block.0': {
|
||||
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
|
||||
},
|
||||
|
||||
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
|
||||
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
|
||||
'desktop_privacy.retention.block.0': {
|
||||
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
|
||||
'zh-CN':
|
||||
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
|
||||
},
|
||||
|
||||
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
|
||||
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
|
||||
'desktop_privacy.rights.block.0': {
|
||||
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
|
||||
'zh-CN':
|
||||
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
|
||||
},
|
||||
'desktop_privacy.rights.block.1': {
|
||||
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
|
||||
},
|
||||
'desktop_privacy.rights.block.2': {
|
||||
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
|
||||
'zh-CN':
|
||||
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
|
||||
},
|
||||
'desktop_privacy.rights.block.3': {
|
||||
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
|
||||
'zh-CN':
|
||||
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
|
||||
},
|
||||
|
||||
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
|
||||
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
|
||||
'desktop_privacy.children.block.0': {
|
||||
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
|
||||
'zh-CN':
|
||||
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
|
||||
},
|
||||
|
||||
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
|
||||
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
|
||||
'desktop_privacy.changes.block.0': {
|
||||
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
|
||||
'zh-CN':
|
||||
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
|
||||
},
|
||||
|
||||
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
|
||||
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
|
||||
'desktop_privacy.contact.block.0': {
|
||||
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
// ── Terms of Service ──────────────────────────────────────────────
|
||||
'tos.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
@@ -4736,6 +4989,8 @@ const translations = {
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
type LocalizedText = Record<Locale, string>
|
||||
|
||||
export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
@@ -4746,4 +5001,4 @@ export function hasKey(key: string): boolean {
|
||||
return key in translations
|
||||
}
|
||||
|
||||
export type { Locale, TranslationKey }
|
||||
export type { Locale, LocalizedText, TranslationKey }
|
||||
|
||||
27
apps/website/src/pages/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
// import EventsSection from '../components/common/EventsSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
// import { learningEvents } from '../data/events'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning — Comfy">
|
||||
<HeroSection client:load />
|
||||
<FeaturedWorkflowSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
13
apps/website/src/pages/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop Privacy Policy — Comfy"
|
||||
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
|
||||
>
|
||||
<HeroSection title="Desktop Privacy Policy" />
|
||||
<ContentSection prefix="desktop_privacy" client:load />
|
||||
</BaseLayout>
|
||||
27
apps/website/src/pages/zh-CN/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
import EventsSection from '../../components/common/EventsSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../config/routes'
|
||||
import { learningEvents } from '../../data/events'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="学习 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<FeaturedWorkflowSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
13
apps/website/src/pages/zh-CN/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop 隐私政策 — Comfy"
|
||||
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
|
||||
>
|
||||
<HeroSection title="Desktop 隐私政策" />
|
||||
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
|
||||
</BaseLayout>
|
||||
115
browser_tests/assets/missing/missing_model_promoted_widget.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"id": "test-missing-model-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "TEST_MISSING_PACK_NODE_A",
|
||||
"pos": [48, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TEST_MISSING_PACK_NODE_B",
|
||||
"pos": [520, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -100,10 +100,7 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async resizeByDragging(
|
||||
element: Locator,
|
||||
{ x, y }: { x?: number; y?: number }
|
||||
) {
|
||||
async dragElementBy(element: Locator, { x, y }: { x?: number; y?: number }) {
|
||||
const elementBox = await element.boundingBox()
|
||||
if (!elementBox) throw new Error('element should have layout')
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -45,6 +45,8 @@ export const TestIds = {
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
missingNodePackExpand: 'missing-node-pack-expand',
|
||||
missingNodePackCount: 'missing-node-pack-count',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
@@ -54,6 +56,7 @@ export const TestIds = {
|
||||
errorDialogFindIssues: 'error-dialog-find-issues',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
errorGroupDisplayMessage: 'error-group-display-message',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
@@ -68,11 +71,6 @@ export const TestIds = {
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
|
||||
export async function intersection(a: Locator, b: Locator) {
|
||||
const aBounds = await a.boundingBox()
|
||||
const bBounds = await b.boundingBox()
|
||||
if (!aBounds || !bBounds) return undefined
|
||||
|
||||
const y = Math.max(aBounds.y, bBounds.y)
|
||||
const x = Math.max(aBounds.x, bBounds.x)
|
||||
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
|
||||
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
|
||||
|
||||
if (y > bot || x > right) return undefined
|
||||
|
||||
const width = right - x
|
||||
const height = bot - y
|
||||
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
@@ -19,63 +19,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
return page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function getSeeErrorsButton(page: Page) {
|
||||
function getDetailsButton(page: Page) {
|
||||
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
}
|
||||
|
||||
test.describe('Labels', () => {
|
||||
test('Should display singular error count label for single error', async ({
|
||||
test('Should display single error copy and View details action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
|
||||
})
|
||||
|
||||
test('Should display "Show missing nodes" button for missing node errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing nodes/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing models/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display "Show missing inputs" button for missing media errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing inputs/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display generic "See Errors" button for multiple error types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/See Errors/i
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).not.toContainText(/1 ERROR/i)
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toHaveText(/\S/)
|
||||
await expect(getDetailsButton(comfyPage.page)).toContainText(
|
||||
/View details/i
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -137,7 +98,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('See Errors flow', () => {
|
||||
test.describe('View details flow', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
@@ -166,7 +127,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
test('"View details" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
@@ -178,7 +139,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
test('"View details" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
@@ -229,19 +190,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
// missing-model nodes could shrink the overlay count. The overlay must
|
||||
// show the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
await expect(overlay).toContainText(/2 errors found/i)
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toHaveText(/Resolve them before running the workflow\./i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
await expect(overlay).toContainText(/2 errors found/i)
|
||||
await expect(
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toHaveText(/Resolve them before running the workflow\./i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe('Topbar commands', () => {
|
||||
type: 'color',
|
||||
defaultValue: '#000000'
|
||||
},
|
||||
selector: '.p-colorpicker-preview'
|
||||
selector: '.color-picker-wrapper > button'
|
||||
}
|
||||
] as const
|
||||
|
||||
@@ -239,8 +239,9 @@ test.describe('Topbar commands', () => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
component.evaluate((el) =>
|
||||
el.tagName === 'INPUT'
|
||||
? (el as HTMLInputElement).disabled
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLButtonElement
|
||||
? el.disabled
|
||||
: el.classList.contains('p-disabled')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Responsive Layout', () => {
|
||||
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Add new keybinding/i })
|
||||
).toBeInViewport()
|
||||
|
||||
const hasHorizontalScroll = await page
|
||||
.locator('.keybinding-panel .p-datatable-table-container')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
|
||||
test('Keybinding column compresses with width while actions stay reachable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
const keybindingList = row.getByTestId('keybinding-list')
|
||||
await expect(keybindingList).toBeVisible()
|
||||
|
||||
const listWidthAt = async (viewportWidth: number) => {
|
||||
await page.setViewportSize({ width: viewportWidth, height: 800 })
|
||||
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
const wideWidth = await listWidthAt(1280)
|
||||
const narrowWidth = await listWidthAt(560)
|
||||
|
||||
expect(narrowWidth).toBeLessThan(wideWidth)
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
63
browser_tests/tests/load3d/load3dSerializeCache.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
type Load3dImageInput = {
|
||||
image: string
|
||||
mask: string
|
||||
normal: string
|
||||
recording: string
|
||||
}
|
||||
|
||||
type PromptBody = {
|
||||
prompt?: Record<
|
||||
string,
|
||||
{ class_type?: string; inputs?: Record<string, unknown> }
|
||||
>
|
||||
}
|
||||
|
||||
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
|
||||
const prompt = (body as PromptBody).prompt ?? {}
|
||||
const node = prompt[nodeId]
|
||||
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
|
||||
const input = node!.inputs!.image as Load3dImageInput
|
||||
expect(typeof input.image).toBe('string')
|
||||
expect(typeof input.recording).toBe('string')
|
||||
return input
|
||||
}
|
||||
|
||||
test.describe('Load3D serialize cache', () => {
|
||||
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
let firstBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
firstBody = body
|
||||
}
|
||||
})
|
||||
const firstInput = getLoad3dImageInput(firstBody, '1')
|
||||
expect(firstInput.recording).toBe('')
|
||||
|
||||
await load3d.recordingButton.click()
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
|
||||
let secondBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
secondBody = body
|
||||
}
|
||||
})
|
||||
const secondInput = getLoad3dImageInput(secondBody, '1')
|
||||
|
||||
expect(
|
||||
secondInput.image,
|
||||
'after starting a recording, the next queue must re-capture ' +
|
||||
'(image filename must change) so the recording is not dropped'
|
||||
).not.toBe(firstInput.image)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSwapNodesGroup,
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -38,6 +39,9 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should filter execution errors by search query', async ({
|
||||
test('Should keep execution errors matching the search query', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
|
||||
await searchInput.fill('nonexistent_query_xyz_12345')
|
||||
await searchInput.fill('Execution failed')
|
||||
|
||||
await expect(runtimePanel).toHaveCount(0)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show error message in runtime error panel', async ({
|
||||
test('Should show runtime error log in the execution error group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openExecutionErrorTab(comfyPage)
|
||||
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.runtimeErrorPanel
|
||||
)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
await expect(runtimePanel).toContainText(/\S/)
|
||||
await expect(runtimePanel).toContainText('Error log')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,35 +5,12 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaUploadDropzone
|
||||
)
|
||||
const [fileChooser] = await Promise.all([
|
||||
comfyPage.page.waitForEvent('filechooser'),
|
||||
dropzone.click()
|
||||
])
|
||||
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
}
|
||||
|
||||
async function confirmPendingSelection(comfyPage: ComfyPage) {
|
||||
const confirmButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaConfirmButton
|
||||
)
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
await confirmButton.click()
|
||||
}
|
||||
|
||||
function getMediaRow(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
|
||||
}
|
||||
|
||||
function getStatusCard(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
|
||||
}
|
||||
|
||||
function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
@@ -46,14 +23,24 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const overlay = getErrorOverlay(comfyPage)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeVisible()
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toContainText(/Load Image/)
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
await expect(
|
||||
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
@@ -67,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
test('Shows missing item label and locate action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -75,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
test.describe('List behavior', () => {
|
||||
test('Clicking the missing item label navigates canvas to the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -108,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
)
|
||||
await librarySelect.getByRole('combobox').click()
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
|
||||
const optionCount = await comfyPage.page.getByRole('option').count()
|
||||
if (optionCount === 0) {
|
||||
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.getByRole('option').first().click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cancel selection', () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
|
||||
await getMediaRow(comfyPage)
|
||||
.getByRole('button', { name: 'Load Image - image', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeHidden()
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('All resolved', () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
})
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,11 +23,31 @@ const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
const emptyMediaLoaderNodes = [
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
serverOnlyOption: 'server-only-image.png',
|
||||
position: { x: 150, y: 150 }
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
serverOnlyOption: 'server-only-video.mp4',
|
||||
position: { x: 450, y: 150 }
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
serverOnlyOption: 'server-only-audio.wav',
|
||||
position: { x: 750, y: 150 }
|
||||
}
|
||||
]
|
||||
|
||||
const cloudOutputAsset: Asset = {
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -36,10 +56,10 @@ const cloudOutputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -50,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -66,12 +86,168 @@ interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
|
||||
type ObjectInfoResponse = Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
>
|
||||
|
||||
function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
) {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
const [valuesOrType, options] = input
|
||||
const optionsObject =
|
||||
options && typeof options === 'object' && !Array.isArray(options)
|
||||
if (Array.isArray(valuesOrType)) {
|
||||
input[0] = values
|
||||
} else if (valuesOrType !== 'COMBO') {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
|
||||
}
|
||||
|
||||
if (optionsObject) {
|
||||
Object.assign(options, { options: values })
|
||||
} else if (!Array.isArray(valuesOrType)) {
|
||||
throw new Error(
|
||||
`Expected ${nodeType}.${inputName} to have options metadata`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
await page.route('**/api/userdata**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
})
|
||||
await page.route('**/i18n', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
await page.route('**/customers/cloud-subscription-status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ is_active: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function routeSetupObjectInfo(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void
|
||||
) {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
customize?.(objectInfo)
|
||||
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await page.route('**/object_info', objectInfoRouteHandler)
|
||||
return async () =>
|
||||
await page.unroute('**/object_info', objectInfoRouteHandler)
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
|
||||
const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
markUploadedCloudAssetAvailable: () => void
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
}
|
||||
@@ -106,9 +282,13 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}
|
||||
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await unrouteObjectInfo()
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
}
|
||||
},
|
||||
markUploadedCloudAssetAvailable: async ({ page }, use) => {
|
||||
await use(() => {
|
||||
@@ -139,7 +319,41 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) {
|
||||
const templatesDialog = comfyPage.page.getByRole('dialog').filter({
|
||||
has: comfyPage.templates.content
|
||||
})
|
||||
const closeButton = templatesDialog.getByRole('button', {
|
||||
name: 'Close dialog'
|
||||
})
|
||||
await closeButton
|
||||
.waitFor({ state: 'visible', timeout: 1_000 })
|
||||
.catch(() => undefined)
|
||||
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click()
|
||||
await expect(templatesDialog).toBeHidden()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate((nodes) => {
|
||||
return nodes.map(({ nodeType, widgetName }) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(graphNode) => graphNode.type === nodeType
|
||||
)
|
||||
const widget = node?.widgets?.find(
|
||||
(candidate) => candidate.name === widgetName
|
||||
)
|
||||
return widget?.value ?? null
|
||||
})
|
||||
}, emptyMediaLoaderNodes)
|
||||
}
|
||||
|
||||
async function delayNextUpload(
|
||||
comfyPage: ComfyPage,
|
||||
uploadResult?: { name: string; subfolder: string; type: 'input' }
|
||||
) {
|
||||
let releaseUpload!: () => void
|
||||
let resolveUploadStarted!: () => void
|
||||
const uploadStarted = new Promise<void>((resolve) => {
|
||||
@@ -152,6 +366,14 @@ async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
const uploadRouteHandler = async (route: Route) => {
|
||||
resolveUploadStarted()
|
||||
await release
|
||||
if (uploadResult) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(uploadResult)
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
}
|
||||
|
||||
@@ -295,12 +517,51 @@ ossTest.describe(
|
||||
}
|
||||
)
|
||||
|
||||
cloudEmptyMediaInputsTest.describe(
|
||||
'Errors tab - Cloud empty media loader inputs',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
await closeTemplatesDialogIfOpen(comfyPage)
|
||||
})
|
||||
|
||||
cloudEmptyMediaInputsTest(
|
||||
'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
await comfyPage.nodeOps.addNode(
|
||||
node.nodeType,
|
||||
undefined,
|
||||
node.position
|
||||
)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'input')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getMediaLoaderWidgetValues(comfyPage))
|
||||
.toEqual(['', '', ''])
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest.describe(
|
||||
'Errors tab - Cloud missing media runtime sources',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
await closeTemplatesDialogIfOpen(comfyPage)
|
||||
})
|
||||
|
||||
cloudOutputTest(
|
||||
@@ -329,13 +590,18 @@ cloudUploadRaceTest.describe(
|
||||
() => {
|
||||
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
await closeTemplatesDialogIfOpen(comfyPage)
|
||||
})
|
||||
|
||||
cloudUploadRaceTest(
|
||||
'does not surface missing media while dropped video upload is in progress',
|
||||
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const delayedUpload = await delayNextUpload(comfyPage)
|
||||
const delayedUpload = await delayNextUpload(comfyPage, {
|
||||
name: plainVideoFileName,
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
|
||||
@@ -25,9 +25,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelsGroup).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeVisible()
|
||||
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
@@ -12,93 +12,97 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
test('Should show missing node pack card with guidance', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
test('Should show unknown pack node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
test('Should locate missing node from the row label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Should toggle grouped pack nodes from chevron and title', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
'missing/missing_nodes_same_pack'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
const packTitle = missingNodeCard.getByRole('button', {
|
||||
name: 'test-missing-node-pack'
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
// TODO: Add navigation assertion once subgraph node ID deduplication
|
||||
// timing is fixed. Currently, collectMissingNodes runs before
|
||||
// configure(), so execution IDs use pre-remapped node IDs that don't
|
||||
// match the runtime graph. See PR #9510 / #8762.
|
||||
const expandButton = missingNodeCard
|
||||
.getByTestId(TestIds.dialogs.missingNodePackExpand)
|
||||
.first()
|
||||
const firstNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_A'
|
||||
})
|
||||
const secondNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_B'
|
||||
})
|
||||
|
||||
await expect(packTitle).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
|
||||
).toHaveText('2')
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await expandButton.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
101
browser_tests/tests/sidebar/assets-output-dedupe.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
/**
|
||||
* Expanded folder view must drop output records that resolve to the same
|
||||
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
|
||||
* v-for in VirtualGrid collides and one asset visibly duplicates its
|
||||
* neighbours while scrolling.
|
||||
*/
|
||||
|
||||
const STACK_JOB_ID = 'job-output-dedupe'
|
||||
const COVER_NODE_ID = '9'
|
||||
const COVER_FILENAME = 'cover_00001_.png'
|
||||
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
|
||||
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
|
||||
|
||||
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
|
||||
// 4 unique composite keys expected after dedupe.
|
||||
const STACK_JOB_OUTPUTS = [
|
||||
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
|
||||
...DISTINCT_FILENAMES.map((filename) => ({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output' as const
|
||||
})),
|
||||
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
|
||||
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
|
||||
]
|
||||
|
||||
const STACK_JOB = createMockJob({
|
||||
id: STACK_JOB_ID,
|
||||
create_time: 5000,
|
||||
execution_start_time: 5000,
|
||||
execution_end_time: 5050,
|
||||
preview_output: {
|
||||
filename: COVER_FILENAME,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: COVER_NODE_ID,
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: STACK_JOB_OUTPUTS.length
|
||||
})
|
||||
|
||||
const STACK_JOB_DETAIL: JobDetail = {
|
||||
...STACK_JOB,
|
||||
outputs: {
|
||||
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
|
||||
}
|
||||
}
|
||||
|
||||
const EXPECTED_TOTAL_TILES = 4
|
||||
|
||||
test.describe(
|
||||
'Expanded folder view dedupes duplicate composite output keys',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
// @cloud comfyPage already navigates with Firebase auth seeded; a second
|
||||
// setup() call would clear localStorage and bounce to /cloud/login.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([STACK_JOB])
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('renders one tile per unique composite key', async ({
|
||||
comfyPage
|
||||
}, testInfo) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
|
||||
|
||||
const labels = await tab.assetCards.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((el) => el.getAttribute('aria-label'))
|
||||
.filter((v): v is string => v !== null)
|
||||
)
|
||||
expect(new Set(labels).size).toBe(labels.length)
|
||||
|
||||
await testInfo.attach('expanded-folder-view.png', {
|
||||
body: await comfyPage.page.screenshot({ fullPage: false }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -289,17 +289,17 @@ test.describe('Node library sidebar', () => {
|
||||
await customColorOption.click()
|
||||
await customColorOption.click()
|
||||
|
||||
// Use the color picker
|
||||
await comfyPage.page
|
||||
.getByLabel('Customize Folder')
|
||||
.getByRole('textbox')
|
||||
.click()
|
||||
await comfyPage.page.locator('.p-colorpicker-color-background').click()
|
||||
|
||||
// Finalize the customization
|
||||
const dialog = comfyPage.page.getByRole('dialog', {
|
||||
name: 'Customize Folder'
|
||||
})
|
||||
await dialog
|
||||
.locator('.color-customization-selector-container > button')
|
||||
.last()
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.getByLabel('Color saturation and brightness')
|
||||
.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
// Select Folder icon (2nd button in Icon group)
|
||||
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
|
||||
await iconGroup.getByRole('button').nth(1).click()
|
||||
|
||||
30
browser_tests/tests/textareaWidgetFontSize.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Textarea widget font size',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('14px')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('22px')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
@@ -1231,3 +1232,41 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Fast disconnection support',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
await comfyMouse.click(100, 100)
|
||||
}
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(slot)
|
||||
await expect.poll(isConnected).toBe(false)
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
}
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
await performDisconnect(ksampler.getSlot('positive'), false)
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await ksampler.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
})
|
||||
|
||||
await test.step('Disconnection with subgraph IO', async () => {
|
||||
await performDisconnect(ksampler.getSlot('negative'), true)
|
||||
await performDisconnect(ksampler.getSlot('latent_image'), false)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,10 +174,50 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
const { bottomRight } = node.resize
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
|
||||
await comfyMouse.dragElementBy(bottomRight, { x: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
|
||||
await comfyMouse.dragElementBy(bottomRight, { x: -200, y: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'requests lightweight thumbnail URLs for grid cells',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
await expect(previewImage).toBeVisible()
|
||||
})
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
const gridImages = node.imageGrid.locator('img')
|
||||
|
||||
await test.step('Inject a multi-image grid', async () => {
|
||||
const images = Array.from({ length: 4 }, (_, index) => ({
|
||||
filename: `grid-${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}))
|
||||
execution.executed('', '1', { images })
|
||||
await expect(gridImages).toHaveCount(4)
|
||||
})
|
||||
|
||||
// FE-741: small on-node grid cells must request a server re-encoded
|
||||
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
|
||||
// downloading the full-resolution image, while still pointing at the
|
||||
// real `/api/view` URL for that output. Verifies the full path: WS
|
||||
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
|
||||
// rendered grid `<img>`.
|
||||
for (const cell of await gridImages.all()) {
|
||||
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
|
||||
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -54,6 +54,35 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
}
|
||||
|
||||
const advancedButtonOverflowPx = 24
|
||||
const holdPointCanvasInsetPx = 8
|
||||
|
||||
const getAdvancedInputsButton = (node: Locator) =>
|
||||
node.getByTestId('advanced-inputs-button')
|
||||
|
||||
const moveAdvancedButtonRightEdgePastCanvas = async (
|
||||
comfyPage: ComfyPage,
|
||||
button: Locator,
|
||||
overflow: number
|
||||
) => {
|
||||
const box = await button.boundingBox()
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!box) throw new Error('Advanced button has no bounding box')
|
||||
if (!canvasBox) throw new Error('Canvas has no bounding box')
|
||||
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
const deltaX = canvasBox.x + canvasBox.width + overflow - box.x - box.width
|
||||
await comfyPage.page.evaluate(
|
||||
({ deltaX, scale }) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] += deltaX / scale
|
||||
canvas.setDirty(true, true)
|
||||
},
|
||||
{ deltaX, scale }
|
||||
)
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -123,7 +152,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const showButton = getAdvancedInputsButton(node)
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
@@ -143,6 +172,83 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'should not pan while holding the Advanced button without dragging',
|
||||
{ tag: ['@canvas', '@widget'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = getAdvancedInputsButton(node)
|
||||
await expect(showButton).toBeVisible()
|
||||
|
||||
await moveAdvancedButtonRightEdgePastCanvas(
|
||||
comfyPage,
|
||||
showButton,
|
||||
advancedButtonOverflowPx
|
||||
)
|
||||
|
||||
const buttonBox = await showButton.boundingBox()
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!buttonBox) throw new Error('Advanced button has no bounding box')
|
||||
if (!canvasBox) throw new Error('Canvas has no bounding box')
|
||||
|
||||
const canvasRight = canvasBox.x + canvasBox.width
|
||||
const buttonRight = buttonBox.x + buttonBox.width
|
||||
expect(
|
||||
buttonRight,
|
||||
'Advanced button should extend past the canvas right edge'
|
||||
).toBeGreaterThan(canvasRight)
|
||||
|
||||
const holdPoint = {
|
||||
x: canvasRight - holdPointCanvasInsetPx,
|
||||
y: buttonBox.y + buttonBox.height / 2
|
||||
}
|
||||
expect(
|
||||
holdPoint.x,
|
||||
'Hold point should stay inside the visible part of the Advanced button'
|
||||
).toBeGreaterThanOrEqual(buttonBox.x)
|
||||
expect(
|
||||
holdPoint.x,
|
||||
'Hold point should stay inside the visible canvas'
|
||||
).toBeLessThanOrEqual(canvasRight)
|
||||
expect(
|
||||
holdPoint.y,
|
||||
'Hold point should stay inside the Advanced button height'
|
||||
).toBeGreaterThanOrEqual(buttonBox.y)
|
||||
expect(
|
||||
holdPoint.y,
|
||||
'Hold point should stay inside the Advanced button height'
|
||||
).toBeLessThanOrEqual(buttonBox.y + buttonBox.height)
|
||||
|
||||
const beforeOffset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(holdPoint.x, holdPoint.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
try {
|
||||
await comfyPage.idleFrames(8)
|
||||
} finally {
|
||||
await comfyPage.page.mouse.up()
|
||||
}
|
||||
|
||||
const afterOffset = await comfyPage.canvasOps.getOffset()
|
||||
expect(afterOffset[0]).toBeCloseTo(beforeOffset[0], 3)
|
||||
expect(afterOffset[1]).toBeCloseTo(beforeOffset[1], 3)
|
||||
}
|
||||
)
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
|
||||
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
|
||||
const FLOAT_SOURCE_POSITION_LEFT_OF_NODE = { x: 100, y: 200 }
|
||||
|
||||
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -32,6 +33,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
async function getWidgetIndex(comfyPage: ComfyPage, widgetName: string) {
|
||||
const index = await comfyPage.page.evaluate((name) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(node) => node.type === 'ModelSamplingFlux'
|
||||
)
|
||||
return node?.widgets?.findIndex((widget) => widget.name === name) ?? -1
|
||||
}, widgetName)
|
||||
expect(
|
||||
index,
|
||||
`${widgetName} widget should exist on ModelSamplingFlux`
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
return index
|
||||
}
|
||||
|
||||
test('should hide advanced widgets by default', async ({ comfyPage }) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
@@ -72,6 +87,47 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const maxShiftWidget = node.getByLabel('max_shift', { exact: true })
|
||||
const baseShiftWidget = node.getByLabel('base_shift', { exact: true })
|
||||
|
||||
await node.getByText(SHOW_ADVANCED_INPUTS).click()
|
||||
await expect(maxShiftWidget).toBeVisible()
|
||||
await expect(baseShiftWidget).toBeVisible()
|
||||
|
||||
const primitive = await comfyPage.nodeOps.addNode(
|
||||
'PrimitiveFloat',
|
||||
{},
|
||||
FLOAT_SOURCE_POSITION_LEFT_OF_NODE
|
||||
)
|
||||
const [target] =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('ModelSamplingFlux')
|
||||
const maxShiftIndex = await getWidgetIndex(comfyPage, 'max_shift')
|
||||
await primitive.connectWidget(0, target, maxShiftIndex)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(node) => node.type === 'ModelSamplingFlux'
|
||||
)
|
||||
return (
|
||||
node?.inputs.find((input) => input.widget?.name === 'max_shift')
|
||||
?.link ?? null
|
||||
)
|
||||
})
|
||||
)
|
||||
.not.toBeNull()
|
||||
|
||||
await node.getByText(HIDE_ADVANCED_INPUTS).click()
|
||||
|
||||
await expect(maxShiftWidget).toBeVisible()
|
||||
await expect(baseShiftWidget).toBeHidden()
|
||||
})
|
||||
|
||||
test('should hide advanced footer button while collapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
|
||||
|
||||
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
async function openSamplerDropdown(comfyPage: ComfyPage) {
|
||||
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
.getByRole('combobox', { name: 'scheduler', exact: true })
|
||||
await expect(schedulerComboAfterReload).toContainText('karras')
|
||||
})
|
||||
|
||||
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const nodeName = 'Resize Image/Mask'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 630 }
|
||||
})
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
|
||||
await node.select()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
|
||||
await combo.click()
|
||||
const dropdown = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
|
||||
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
|
||||
const cX = bounds.x + bounds.width / 2
|
||||
const cY = bounds.y + bounds.height / 2
|
||||
const dropdownBounds = (await dropdown.boundingBox())!
|
||||
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
|
||||
await dropdown.click({ position, trial: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,11 +40,11 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
await expect(gutter).toBeVisible()
|
||||
await comfyMouse.resizeByDragging(gutter, { x: -200 })
|
||||
await comfyMouse.dragElementBy(gutter, { x: -200 })
|
||||
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
|
||||
const intermediateWidth = await getWidth()
|
||||
|
||||
await comfyMouse.resizeByDragging(gutter, { x: 100 })
|
||||
await comfyMouse.dragElementBy(gutter, { x: 100 })
|
||||
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
|
||||
})
|
||||
})
|
||||
|
||||
14
global.d.ts
vendored
@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface RewardfulGlobal {
|
||||
referral?: string
|
||||
affiliate?: { id?: string; token?: string; name?: string }
|
||||
campaign?: { id?: string; name?: string }
|
||||
}
|
||||
|
||||
interface RewardfulQueueFunction {
|
||||
(method: 'ready', callback: () => void): void
|
||||
(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
@@ -63,6 +75,8 @@ interface Window {
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
Rewardful?: RewardfulGlobal
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/images/comfy-logo-single.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
"src": "/assets/images/comfy-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/images/comfy-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"display": "standalone"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.8",
|
||||
"version": "1.46.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -8,12 +8,12 @@
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud vite build --config vite.config.mts",
|
||||
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
|
||||
"build": "pnpm typecheck && vite build --config vite.config.mts",
|
||||
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
|
||||
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--workflow-tabs-height: 2.375rem;
|
||||
--comfy-input-bg: #222;
|
||||
--comfy-textarea-font-size: 10px;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
--drag-text: #ccc;
|
||||
|
||||
@@ -28,13 +28,15 @@ export type {
|
||||
BillingPlansResponse,
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
BulkRevokeApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysData,
|
||||
BulkRevokeWorkspaceMemberApiKeysError,
|
||||
BulkRevokeWorkspaceMemberApiKeysErrors,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponses,
|
||||
CancelAssetSeedData,
|
||||
CancelAssetSeedResponse,
|
||||
CancelAssetSeedResponses,
|
||||
CancelJobData,
|
||||
CancelJobError,
|
||||
CancelJobErrors,
|
||||
@@ -57,11 +59,14 @@ export type {
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClientOptions,
|
||||
CreateAssetData,
|
||||
CreateAssetDownloadData,
|
||||
CreateAssetDownloadError,
|
||||
CreateAssetDownloadErrors,
|
||||
CreateAssetDownloadResponse,
|
||||
CreateAssetDownloadResponses,
|
||||
CreateAssetError,
|
||||
CreateAssetErrors,
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportError,
|
||||
CreateAssetExportErrors,
|
||||
@@ -72,6 +77,8 @@ export type {
|
||||
CreateAssetFromHashErrors,
|
||||
CreateAssetFromHashResponse,
|
||||
CreateAssetFromHashResponses,
|
||||
CreateAssetResponse,
|
||||
CreateAssetResponses,
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
@@ -208,6 +215,8 @@ export type {
|
||||
ForkWorkflowRequest,
|
||||
ForkWorkflowResponse,
|
||||
ForkWorkflowResponses,
|
||||
FreeMemoryData,
|
||||
FreeMemoryResponses,
|
||||
GetAllSettingsData,
|
||||
GetAllSettingsError,
|
||||
GetAllSettingsErrors,
|
||||
@@ -221,6 +230,9 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
GetAssetTagHistogramData,
|
||||
GetAssetTagHistogramError,
|
||||
GetAssetTagHistogramErrors,
|
||||
@@ -259,6 +271,9 @@ export type {
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetEmbeddingsData,
|
||||
GetEmbeddingsResponse,
|
||||
GetEmbeddingsResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponse,
|
||||
GetExtensionsResponses,
|
||||
@@ -305,6 +320,18 @@ export type {
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetI18nData,
|
||||
GetI18nResponse,
|
||||
GetI18nResponses,
|
||||
GetInternalFolderPathsData,
|
||||
GetInternalFolderPathsResponse,
|
||||
GetInternalFolderPathsResponses,
|
||||
GetInternalLogsData,
|
||||
GetInternalLogsRawData,
|
||||
GetInternalLogsRawResponse,
|
||||
GetInternalLogsRawResponses,
|
||||
GetInternalLogsResponse,
|
||||
GetInternalLogsResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
@@ -356,10 +383,7 @@ export type {
|
||||
GetModelFoldersResponse,
|
||||
GetModelFoldersResponses,
|
||||
GetModelPreviewData,
|
||||
GetModelPreviewError,
|
||||
GetModelPreviewErrors,
|
||||
GetModelPreviewResponse,
|
||||
GetModelPreviewResponses,
|
||||
GetModelsInFolderData,
|
||||
GetModelsInFolderError,
|
||||
GetModelsInFolderErrors,
|
||||
@@ -389,8 +413,26 @@ export type {
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetOAuthAuthorizationServerData,
|
||||
GetOAuthAuthorizationServerError,
|
||||
GetOAuthAuthorizationServerErrors,
|
||||
GetOAuthAuthorizationServerResponse,
|
||||
GetOAuthAuthorizationServerResponses,
|
||||
GetOAuthAuthorizeData,
|
||||
GetOAuthAuthorizeError,
|
||||
GetOAuthAuthorizeErrors,
|
||||
GetOAuthAuthorizeResponse,
|
||||
GetOAuthAuthorizeResponses,
|
||||
GetOAuthProtectedResourceByPathData,
|
||||
GetOAuthProtectedResourceByPathError,
|
||||
GetOAuthProtectedResourceByPathErrors,
|
||||
GetOAuthProtectedResourceByPathResponse,
|
||||
GetOAuthProtectedResourceByPathResponses,
|
||||
GetOAuthProtectedResourceData,
|
||||
GetOAuthProtectedResourceError,
|
||||
GetOAuthProtectedResourceErrors,
|
||||
GetOAuthProtectedResourceResponse,
|
||||
GetOAuthProtectedResourceResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
@@ -427,11 +469,11 @@ export type {
|
||||
GetSecretErrors,
|
||||
GetSecretResponse,
|
||||
GetSecretResponses,
|
||||
GetSettingByKeyData,
|
||||
GetSettingByKeyError,
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetSettingByIdData,
|
||||
GetSettingByIdError,
|
||||
GetSettingByIdErrors,
|
||||
GetSettingByIdResponse,
|
||||
GetSettingByIdResponses,
|
||||
GetStaticExtensionsData,
|
||||
GetStaticExtensionsErrors,
|
||||
GetStaticExtensionsResponses,
|
||||
@@ -447,6 +489,7 @@ export type {
|
||||
GetTaskResponses,
|
||||
GetTemplateProxyData,
|
||||
GetTemplateProxyErrors,
|
||||
GetTemplateProxyResponses,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
@@ -534,6 +577,11 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InsertDynamicConfigData,
|
||||
InsertDynamicConfigError,
|
||||
InsertDynamicConfigErrors,
|
||||
InsertDynamicConfigResponse,
|
||||
InsertDynamicConfigResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
@@ -642,6 +690,17 @@ export type {
|
||||
MoveUserdataFileResponse,
|
||||
MoveUserdataFileResponses,
|
||||
NodeInfo,
|
||||
OAuthAuthorizationServerMetadata,
|
||||
OAuthAuthorizeRedirectResponse,
|
||||
OAuthConsentChallenge,
|
||||
OAuthConsentChallengeWorkspace,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthRegisterBadRequestResponse,
|
||||
OAuthRegisterError,
|
||||
OAuthRegisterRequest,
|
||||
OAuthRegisterResponse,
|
||||
OAuthTokenError,
|
||||
OAuthTokenResponse,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
@@ -663,6 +722,21 @@ export type {
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
PostOAuthAuthorizeData,
|
||||
PostOAuthAuthorizeError,
|
||||
PostOAuthAuthorizeErrors,
|
||||
PostOAuthAuthorizeResponse,
|
||||
PostOAuthAuthorizeResponses,
|
||||
PostOAuthRegisterData,
|
||||
PostOAuthRegisterError,
|
||||
PostOAuthRegisterErrors,
|
||||
PostOAuthRegisterResponse,
|
||||
PostOAuthRegisterResponses,
|
||||
PostOAuthTokenData,
|
||||
PostOAuthTokenError,
|
||||
PostOAuthTokenErrors,
|
||||
PostOAuthTokenResponse,
|
||||
PostOAuthTokenResponses,
|
||||
PostPprofSymbolData,
|
||||
PostPprofSymbolResponses,
|
||||
PostUserdataFileData,
|
||||
@@ -687,6 +761,9 @@ export type {
|
||||
PromptInfo,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
PruneAssetsData,
|
||||
PruneAssetsResponse,
|
||||
PruneAssetsResponses,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
@@ -732,6 +809,9 @@ export type {
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
SeedAssetsResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
@@ -751,6 +831,8 @@ export type {
|
||||
SubscribeResponse,
|
||||
SubscribeResponse2,
|
||||
SubscribeResponses,
|
||||
SubscribeToLogsData,
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SyncApiKeyData,
|
||||
@@ -771,11 +853,6 @@ export type {
|
||||
UpdateAssetErrors,
|
||||
UpdateAssetResponse,
|
||||
UpdateAssetResponses,
|
||||
UpdateAssetTagsData,
|
||||
UpdateAssetTagsError,
|
||||
UpdateAssetTagsErrors,
|
||||
UpdateAssetTagsResponse,
|
||||
UpdateAssetTagsResponses,
|
||||
UpdateHubProfileData,
|
||||
UpdateHubProfileError,
|
||||
UpdateHubProfileErrors,
|
||||
@@ -799,11 +876,11 @@ export type {
|
||||
UpdateSecretRequest,
|
||||
UpdateSecretResponse,
|
||||
UpdateSecretResponses,
|
||||
UpdateSettingByKeyData,
|
||||
UpdateSettingByKeyError,
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSettingByIdData,
|
||||
UpdateSettingByIdError,
|
||||
UpdateSettingByIdErrors,
|
||||
UpdateSettingByIdResponse,
|
||||
UpdateSettingByIdResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
@@ -821,11 +898,6 @@ export type {
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
UploadAssetData,
|
||||
UploadAssetError,
|
||||
UploadAssetErrors,
|
||||
UploadAssetResponse,
|
||||
UploadAssetResponses,
|
||||
UploadImageData,
|
||||
UploadImageError,
|
||||
UploadImageErrors,
|
||||
|
||||
1263
packages/ingest-types/src/types.gen.ts
generated
602
packages/ingest-types/src/zod.gen.ts
generated
@@ -399,13 +399,18 @@ export const zCreateWorkflowVersionRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Offset/limit-based pagination metadata included in list responses.
|
||||
* Pagination metadata included in list responses. Supports both legacy
|
||||
* offset/limit pagination and cursor-based pagination. When cursor-based
|
||||
* pagination is used, `next_cursor` is the primary pagination token and
|
||||
* `offset`/`total` may be zero.
|
||||
*
|
||||
*/
|
||||
export const zPaginationInfo = z.object({
|
||||
offset: z.number().int().gte(0),
|
||||
limit: z.number().int().gte(1),
|
||||
total: z.number().int().gte(0),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -879,6 +884,155 @@ export const zJwkKey = z.object({
|
||||
y: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.2 error response.
|
||||
*/
|
||||
export const zOAuthTokenError = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.1 successful token response.
|
||||
*/
|
||||
export const zOAuthTokenResponse = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.enum(['Bearer']),
|
||||
expires_in: z.number().int(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallengeWorkspace = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['personal', 'team']),
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
|
||||
*/
|
||||
export const zOAuthAuthorizeRedirectResponse = z.object({
|
||||
redirect_url: z.string().url()
|
||||
})
|
||||
|
||||
/**
|
||||
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallenge = z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 protected-resource metadata (RFC 9728).
|
||||
*/
|
||||
export const zOAuthProtectedResourceMetadata = z.object({
|
||||
resource: z.string().url(),
|
||||
authorization_servers: z.array(z.string().url()),
|
||||
scopes_supported: z.array(z.string()),
|
||||
bearer_methods_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.2 error response.
|
||||
*/
|
||||
export const zOAuthRegisterError = z.object({
|
||||
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
|
||||
error_description: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterBadRequestResponse = z.union([
|
||||
zOAuthRegisterError,
|
||||
zErrorResponse
|
||||
])
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.1 successful registration response.
|
||||
*/
|
||||
export const zOAuthRegisterResponse = z.object({
|
||||
client_id: z.string(),
|
||||
client_id_issued_at: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
client_name: z.string().optional(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.enum(['none']),
|
||||
application_type: z.enum(['native', 'web'])
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterRequest = z.object({
|
||||
redirect_uris: z.array(z.string()).min(1).max(5),
|
||||
client_name: z.string().max(100).optional(),
|
||||
application_type: z.enum(['native', 'web']).optional(),
|
||||
token_endpoint_auth_method: z.enum(['none']).optional(),
|
||||
grant_types: z
|
||||
.array(z.enum(['authorization_code', 'refresh_token']))
|
||||
.optional(),
|
||||
response_types: z.array(z.enum(['code'])).optional(),
|
||||
scope: z.string().nullish(),
|
||||
resource_grants: z.record(z.array(z.string())).nullish(),
|
||||
client_uri: z.string().nullish(),
|
||||
logo_uri: z.string().nullish(),
|
||||
tos_uri: z.string().nullish(),
|
||||
policy_uri: z.string().nullish(),
|
||||
software_id: z.string().nullish(),
|
||||
software_version: z.string().nullish(),
|
||||
contacts: z.array(z.string()).nullish(),
|
||||
jwks: z.record(z.unknown()).nullish(),
|
||||
jwks_uri: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 authorization-server metadata (RFC 8414).
|
||||
*/
|
||||
export const zOAuthAuthorizationServerMetadata = z.object({
|
||||
issuer: z.string().url(),
|
||||
authorization_endpoint: z.string().url(),
|
||||
token_endpoint: z.string().url(),
|
||||
jwks_uri: z.string().url(),
|
||||
registration_endpoint: z.string().url().optional(),
|
||||
response_types_supported: z.array(z.string()),
|
||||
grant_types_supported: z.array(z.string()),
|
||||
code_challenge_methods_supported: z.array(z.string()),
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()),
|
||||
scopes_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
|
||||
*/
|
||||
@@ -940,6 +1094,7 @@ export const zWorkspaceApiKeyInfo = z.object({
|
||||
workspace_id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
last_used_at: z.string().datetime().optional(),
|
||||
@@ -960,6 +1115,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
|
||||
export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
@@ -971,6 +1127,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
*/
|
||||
export const zCreateWorkspaceApiKeyRequest = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().max(5000).optional(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
@@ -1353,7 +1510,8 @@ export const zListTagsResponse = z.object({
|
||||
export const zAsset = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
@@ -1364,19 +1522,20 @@ export const zAsset = z.object({
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
mime_type: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
is_immutable: z.boolean().optional()
|
||||
is_immutable: z.boolean().optional(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1385,7 +1544,8 @@ export const zAsset = z.object({
|
||||
export const zListAssetsResponse = z.object({
|
||||
assets: z.array(zAsset),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1394,14 +1554,17 @@ export const zListAssetsResponse = z.object({
|
||||
export const zAssetUpdated = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().optional(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
mime_type: z.string().optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
updated_at: z.string().datetime()
|
||||
job_id: z.string().uuid().nullish(),
|
||||
updated_at: z.string().datetime(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1753,21 +1916,6 @@ export const zExportDownloadUrlResponse = z.object({
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export const zBindingErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response returned after successfully queuing a workflow prompt.
|
||||
*/
|
||||
@@ -1796,7 +1944,8 @@ export const zPromptRequest = z.object({
|
||||
export const zAssetWritable = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
@@ -1807,18 +1956,19 @@ export const zAssetWritable = z.object({
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
mime_type: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
is_immutable: z.boolean().optional()
|
||||
is_immutable: z.boolean().optional(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1827,7 +1977,8 @@ export const zAssetWritable = z.object({
|
||||
export const zListAssetsResponseWritable = z.object({
|
||||
assets: z.array(zAssetWritable),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1961,21 +2112,6 @@ export const zGetModelsInFolderData = z.object({
|
||||
*/
|
||||
export const zGetModelsInFolderResponse = z.array(zModelFile)
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Model preview image
|
||||
*/
|
||||
export const zGetModelPreviewResponse = z.string()
|
||||
|
||||
export const zGetLegacyHistoryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2027,6 +2163,7 @@ export const zListJobsData = z.object({
|
||||
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
|
||||
sort_by: z.enum(['create_time', 'execution_time']).optional(),
|
||||
sort_order: z.enum(['asc', 'desc']).optional(),
|
||||
after: z.string().optional(),
|
||||
offset: z.number().int().gte(0).optional().default(0),
|
||||
limit: z.number().int().gte(1).lte(1000).optional().default(100)
|
||||
})
|
||||
@@ -2132,9 +2269,9 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
hash: z.string().optional(),
|
||||
after: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -2144,26 +2281,34 @@ export const zListAssetsData = z.object({
|
||||
*/
|
||||
export const zListAssetsResponse2 = zListAssetsResponse
|
||||
|
||||
export const zUploadAssetData = z.object({
|
||||
export const zCreateAssetData = z.object({
|
||||
body: z.object({
|
||||
url: z.string().url(),
|
||||
name: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_id: z.string().uuid().optional()
|
||||
file: z.string(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
tags: z.string().optional(),
|
||||
id: z.string().uuid().optional(),
|
||||
preview_id: z.string().uuid().optional(),
|
||||
name: z.string().optional(),
|
||||
mime_type: z.string().optional(),
|
||||
user_metadata: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset already exists (returned existing asset)
|
||||
* Asset already existed for this user (deduplicated by content hash); the
|
||||
* existing asset is returned with created_new=false.
|
||||
*
|
||||
*/
|
||||
export const zUploadAssetResponse = zAssetCreated
|
||||
export const zCreateAssetResponse = zAssetCreated
|
||||
|
||||
export const zCreateAssetFromHashData = z.object({
|
||||
body: z.object({
|
||||
hash: z.string().regex(/^(blake3|sha256):[a-f0-9]{64}$/),
|
||||
hash: z.string().regex(/^blake3:[a-f0-9]{64}$/),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).min(1),
|
||||
mime_type: z.string().optional(),
|
||||
@@ -2174,7 +2319,9 @@ export const zCreateAssetFromHashData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset reference already exists (returned existing)
|
||||
* Asset reference already existed for this user (deduplicated by content
|
||||
* hash); the existing asset is returned with created_new=false.
|
||||
*
|
||||
*/
|
||||
export const zCreateAssetFromHashResponse = zAssetCreated
|
||||
|
||||
@@ -2214,7 +2361,8 @@ export const zCreateAssetExportData = z.object({
|
||||
naming_strategy: z
|
||||
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
|
||||
.optional(),
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
|
||||
include_previews: z.boolean().optional().default(false)
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
@@ -2247,7 +2395,7 @@ export const zDeleteAssetData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset deleted successfully
|
||||
* Asset record deleted successfully
|
||||
*/
|
||||
export const zDeleteAssetResponse = z.void()
|
||||
|
||||
@@ -2312,22 +2460,6 @@ export const zAddAssetTagsData = z.object({
|
||||
*/
|
||||
export const zAddAssetTagsResponse = zTagsModificationResponse
|
||||
|
||||
export const zUpdateAssetTagsData = z.object({
|
||||
body: z.object({
|
||||
add: z.array(z.string()).optional(),
|
||||
remove: z.array(z.string()).optional()
|
||||
}),
|
||||
path: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Tags updated successfully
|
||||
*/
|
||||
export const zUpdateAssetTagsResponse = zTagsModificationResponse
|
||||
|
||||
export const zListTagsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2509,10 +2641,10 @@ export const zUpdateMultipleSettingsData = z.object({
|
||||
*/
|
||||
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetSettingByKeyData = z.object({
|
||||
export const zGetSettingByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2520,14 +2652,14 @@ export const zGetSettingByKeyData = z.object({
|
||||
/**
|
||||
* Setting value response
|
||||
*/
|
||||
export const zGetSettingByKeyResponse = z.object({
|
||||
export const zGetSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const zUpdateSettingByKeyData = z.object({
|
||||
export const zUpdateSettingByIdData = z.object({
|
||||
body: z.unknown(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2535,7 +2667,7 @@ export const zUpdateSettingByKeyData = z.object({
|
||||
/**
|
||||
* Updated setting value response
|
||||
*/
|
||||
export const zUpdateSettingByKeyResponse = z.object({
|
||||
export const zUpdateSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
@@ -2691,21 +2823,7 @@ export const zUploadMaskData = z.object({
|
||||
export const zUploadMaskResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
is_mask: z.boolean().optional(),
|
||||
original_hash: z.string().optional(),
|
||||
mask_type: z.string().optional(),
|
||||
related_files: z
|
||||
.object({
|
||||
mask: z.string().optional(),
|
||||
paint: z.string().optional(),
|
||||
painted: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
type: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetLogsData = z.object({
|
||||
@@ -2774,6 +2892,115 @@ export const zGetJwksData = z.object({
|
||||
*/
|
||||
export const zGetJwksResponse = zJwksResponse
|
||||
|
||||
export const zGetOAuthAuthorizationServerData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Authorization-server metadata
|
||||
*/
|
||||
export const zGetOAuthAuthorizationServerResponse =
|
||||
zOAuthAuthorizationServerMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceByPathData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceByPathResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthAuthorizeData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
response_type: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.string().optional(),
|
||||
resource: z.string().optional(),
|
||||
oauth_request_id: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
|
||||
*
|
||||
*/
|
||||
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
|
||||
|
||||
export const zPostOAuthAuthorizeData = z.object({
|
||||
body: z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
workspace_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
|
||||
*/
|
||||
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
|
||||
|
||||
export const zPostOAuthTokenData = z.object({
|
||||
body: z.object({
|
||||
grant_type: z.enum(['authorization_code', 'refresh_token']),
|
||||
client_id: z.string(),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
client_secret: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* New token pair
|
||||
*/
|
||||
export const zPostOAuthTokenResponse = zOAuthTokenResponse
|
||||
|
||||
export const zPostOAuthRegisterData = z.object({
|
||||
body: zOAuthRegisterRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
|
||||
*/
|
||||
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
|
||||
|
||||
export const zListWorkspacesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3078,6 +3305,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Config inserted successfully
|
||||
*/
|
||||
export const zInsertDynamicConfigResponse = z.object({
|
||||
id: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
export const zSyncApiKeyData = z.object({
|
||||
body: zSyncApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3671,12 +3920,6 @@ export const zGetHealthData = z.object({
|
||||
*/
|
||||
export const zGetHealthResponse = z.string()
|
||||
|
||||
export const zGetOpenapiSpecData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetMonitoringTasksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3757,6 +4000,16 @@ export const zPostCustomNodeProxyData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -3832,3 +4085,150 @@ export const zGetLegacyViewMetadataData = z.object({
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetEmbeddingsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Embedding names
|
||||
*/
|
||||
export const zGetEmbeddingsResponse = z.array(z.string())
|
||||
|
||||
export const zFreeMemoryData = z.object({
|
||||
body: z
|
||||
.object({
|
||||
unload_models: z.boolean().optional(),
|
||||
free_memory: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetI18nData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Nested map of locale to translation key-value pairs
|
||||
*/
|
||||
export const zGetI18nResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetInternalFolderPathsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Map of folder type name to list of path entries
|
||||
*/
|
||||
export const zGetInternalFolderPathsResponse = z.record(
|
||||
z.array(z.array(z.string()))
|
||||
)
|
||||
|
||||
export const zGetInternalLogsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Log text
|
||||
*/
|
||||
export const zGetInternalLogsResponse = z.string()
|
||||
|
||||
export const zGetInternalLogsRawData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Structured log data
|
||||
*/
|
||||
export const zGetInternalLogsRawResponse = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
t: z.number().optional(),
|
||||
m: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
size: z
|
||||
.object({
|
||||
cols: z.number().int().optional(),
|
||||
rows: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zSubscribeToLogsData = z.object({
|
||||
body: z.object({
|
||||
clientId: z.string(),
|
||||
enabled: z.boolean()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPruneAssetsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Prune result
|
||||
*/
|
||||
export const zPruneAssetsResponse = z.object({
|
||||
status: z.string().optional(),
|
||||
marked: z.number().int().optional()
|
||||
})
|
||||
|
||||
export const zSeedAssetsData = z.object({
|
||||
body: z
|
||||
.object({
|
||||
roots: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Seed started
|
||||
*/
|
||||
export const zSeedAssetsResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetAssetSeedStatusData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan progress details (files scanned, total, status, etc.)
|
||||
*/
|
||||
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
|
||||
|
||||
export const zCancelAssetSeedData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan cancelled
|
||||
*/
|
||||
export const zCancelAssetSeedResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
1558
pnpm-lock.yaml
generated
@@ -2,6 +2,8 @@ packages:
|
||||
- apps/**
|
||||
- packages/**
|
||||
|
||||
nodeOptions: '${NODE_OPTIONS:- } --no-experimental-webstorage --max-old-space-size=8192'
|
||||
|
||||
ignoreWorkspaceRootCheck: true
|
||||
catalogMode: prefer
|
||||
publicHoistPattern:
|
||||
@@ -130,7 +132,7 @@ catalog:
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^4.0.16
|
||||
vitest: ^4.1.0
|
||||
vue: ^3.5.34
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
|
||||
BIN
public/assets/images/comfy-icon-192.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
public/assets/images/comfy-icon-512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
|
||||
|
||||
@@ -14,6 +14,12 @@ describe('ColorCustomizationSelector', () => {
|
||||
{ name: 'Green', value: '#28a745' }
|
||||
]
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { color: { hex: 'Hex', rgba: 'RGBA' } } }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
@@ -27,8 +33,8 @@ describe('ColorCustomizationSelector', () => {
|
||||
|
||||
const result = render(ColorCustomizationSelector, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { SelectButton, ColorPicker }
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: { SelectButton }
|
||||
},
|
||||
props: {
|
||||
modelValue: null,
|
||||
@@ -68,24 +74,21 @@ describe('ColorCustomizationSelector', () => {
|
||||
const buttons = getToggleButtons(container)
|
||||
const customButton = buttons[buttons.length - 1]
|
||||
expect(customButton).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker uses readonly input preview with no ARIA role
|
||||
const colorPreview = container.querySelector(
|
||||
'.p-colorpicker-preview'
|
||||
) as HTMLInputElement | null
|
||||
expect(colorPreview).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows color picker when custom option is selected', async () => {
|
||||
const { container, user } = renderComponent()
|
||||
const { container, user } = renderComponent({ modelValue: '#0d6efd' })
|
||||
await nextTick()
|
||||
|
||||
const buttons = getToggleButtons(container)
|
||||
await user.click(buttons[buttons.length - 1])
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container -- count buttons to detect the ColorPicker popover trigger appearing
|
||||
const initialButtonCount = container.querySelectorAll('button').length
|
||||
const toggleButtons = getToggleButtons(container)
|
||||
await user.click(toggleButtons[toggleButtons.length - 1])
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker internal DOM
|
||||
container.querySelector('[data-pc-name="colorpicker"]')
|
||||
).not.toBeNull()
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container -- count buttons to detect the ColorPicker popover trigger appearing
|
||||
const afterButtonCount = container.querySelectorAll('button').length
|
||||
expect(afterButtonCount).toBe(initialButtonCount + 1)
|
||||
})
|
||||
|
||||
it('emits update when predefined color is selected', async () => {
|
||||
@@ -117,7 +120,7 @@ describe('ColorCustomizationSelector', () => {
|
||||
onUpdate.mockClear()
|
||||
await user.click(buttons[buttons.length - 1]) // Switch to custom
|
||||
|
||||
// When switching to custom, the custom color value inherits from Blue ('0d6efd')
|
||||
// When switching to custom, the custom color value inherits from Blue
|
||||
// and the watcher on customColorValue emits the update
|
||||
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
|
||||
})
|
||||
|
||||
@@ -30,10 +30,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
colorOptions,
|
||||
@@ -65,7 +66,7 @@ onMounted(() => {
|
||||
selectedColorOption.value = predefinedColor
|
||||
} else {
|
||||
selectedColorOption.value = customColorOption
|
||||
customColorValue.value = modelValue.replace('#', '')
|
||||
customColorValue.value = modelValue
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -74,7 +75,7 @@ onMounted(() => {
|
||||
watch(selectedColorOption, (newOption, oldOption) => {
|
||||
if (newOption.name === '_custom') {
|
||||
// Inherit the color from previous selection
|
||||
customColorValue.value = oldOption.value.replace('#', '')
|
||||
customColorValue.value = oldOption.value
|
||||
} else {
|
||||
emit('update:modelValue', newOption.value)
|
||||
}
|
||||
@@ -82,7 +83,7 @@ watch(selectedColorOption, (newOption, oldOption) => {
|
||||
|
||||
watch(customColorValue, (newValue) => {
|
||||
if (selectedColorOption.value.name === '_custom') {
|
||||
emit('update:modelValue', newValue ? `#${newValue}` : null)
|
||||
emit('update:modelValue', newValue || null)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
<Dialog v-model:open="visible" :modal="false">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
<DialogContent size="md" :aria-labelledby="titleId">
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{ $t('g.customizeFolder') }}
|
||||
@@ -92,21 +88,6 @@ const emit = defineEmits<{
|
||||
|
||||
const titleId = useId()
|
||||
|
||||
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
|
||||
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
|
||||
// overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
|
||||
|
||||
function onPointerDownOutside(
|
||||
event: CustomEvent<{ originalEvent: PointerEvent }>
|
||||
) {
|
||||
const target = event.detail.originalEvent.target
|
||||
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const iconOptions = [
|
||||
|
||||
110
src/components/common/FormColorPicker.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import FormColorPicker from './FormColorPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
color: {
|
||||
hex: 'Hex',
|
||||
rgba: 'RGBA',
|
||||
saturationBrightness: 'Color saturation and brightness',
|
||||
hue: 'Hue',
|
||||
alpha: 'Alpha'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderForm(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(FormColorPicker, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { modelValue: '000000', ...props }
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('FormColorPicker', () => {
|
||||
it('preserves the legacy no-# storage contract on commit', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderForm({
|
||||
modelValue: '000000',
|
||||
label: 'Color',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '#abcdef{enter}')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith('abcdef')
|
||||
})
|
||||
|
||||
it('does not commit incomplete hex while typing', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderForm({
|
||||
modelValue: '000000',
|
||||
label: 'Color',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '#ab')
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reverts to current value when partial entry is committed', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderForm({
|
||||
modelValue: '282828',
|
||||
label: 'Color',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '#ab{enter}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(input.value).toBe('282828')
|
||||
})
|
||||
|
||||
it('accepts 8-digit hex (with alpha) on commit', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { user } = renderForm({
|
||||
modelValue: '000000',
|
||||
label: 'Color',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '#11223344{enter}')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith('11223344')
|
||||
})
|
||||
|
||||
it('disables both inputs when disabled prop is set', () => {
|
||||
const { container } = renderForm({
|
||||
modelValue: '000000',
|
||||
label: 'Color',
|
||||
disabled: true
|
||||
})
|
||||
const textInput = screen.getByPlaceholderText('Color') as HTMLInputElement
|
||||
expect(textInput.disabled).toBe(true)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container -- the picker trigger has no stable accessible name
|
||||
const trigger = container.querySelector(
|
||||
'.color-picker-wrapper > button'
|
||||
) as HTMLButtonElement | null
|
||||
expect(trigger).not.toBeNull()
|
||||
expect(trigger?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,76 @@
|
||||
<template>
|
||||
<div class="color-picker-wrapper flex items-center gap-2">
|
||||
<ColorPicker v-model="modelValue" v-bind="$attrs" />
|
||||
<InputText v-model="modelValue" class="w-28" :placeholder="label" />
|
||||
<ColorPicker
|
||||
:id="id"
|
||||
v-model="hexValue"
|
||||
:disabled="disabled"
|
||||
:aria-labelledby="ariaLabelledby"
|
||||
/>
|
||||
<Input
|
||||
v-model="draftText"
|
||||
class="w-28"
|
||||
:placeholder="label"
|
||||
:disabled="disabled"
|
||||
@blur="commitDraft"
|
||||
@keydown.enter="commitDraft"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
defineProps<{
|
||||
const {
|
||||
disabled = false,
|
||||
id,
|
||||
ariaLabelledby
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
ariaLabelledby?: string
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
// Preserve the PrimeVue ColorPicker storage contract (hex without `#`); the
|
||||
// underlying picker uses `#`-prefixed hex, so normalize on read/write.
|
||||
const hexValue = computed<string>({
|
||||
get: () =>
|
||||
modelValue.value?.startsWith('#')
|
||||
? modelValue.value
|
||||
: `#${modelValue.value ?? '000000'}`,
|
||||
set: (next) => {
|
||||
modelValue.value = next.replace(/^#/, '')
|
||||
}
|
||||
})
|
||||
|
||||
// Free-text draft so partial typing (e.g. "#f") doesn't roundtrip through
|
||||
// the picker and snap back to black. Only commit on blur or Enter when the
|
||||
// input fully parses as 6- or 8-digit hex.
|
||||
const draftText = ref(modelValue.value ?? '')
|
||||
watch(modelValue, (next) => {
|
||||
draftText.value = next ?? ''
|
||||
})
|
||||
|
||||
const FULL_HEX = /^#?([0-9a-f]{6}|[0-9a-f]{8})$/i
|
||||
|
||||
function commitDraft() {
|
||||
const raw = draftText.value.trim()
|
||||
if (raw === '') {
|
||||
draftText.value = modelValue.value ?? ''
|
||||
return
|
||||
}
|
||||
if (FULL_HEX.test(raw)) {
|
||||
modelValue.value = raw.replace(/^#/, '').toLowerCase()
|
||||
} else {
|
||||
draftText.value = modelValue.value ?? ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
@@ -46,7 +46,10 @@
|
||||
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div @contextmenu.capture="clearContextMenuTarget">
|
||||
<div
|
||||
class="min-w-0 overflow-x-hidden"
|
||||
@contextmenu.capture="clearContextMenuTarget"
|
||||
>
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
v-model:expanded-rows="expandedRows"
|
||||
@@ -60,6 +63,7 @@
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
:table-style="{ tableLayout: 'fixed', width: '100%' }"
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@@ -71,12 +75,11 @@
|
||||
field="id"
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="flex items-center gap-1 truncate"
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
@@ -103,53 +106,38 @@
|
||||
<Column
|
||||
field="keybindings"
|
||||
:header="$t('g.keybinding')"
|
||||
:style="{ width: '30%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
v-if="slotProps.data.keybindings.length > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template
|
||||
v-for="(binding, idx) in (
|
||||
slotProps.data as ICommandData
|
||||
).keybindings.slice(0, 2)"
|
||||
:key="binding.combo.serialize()"
|
||||
>
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="slotProps.data.keybindings.length > 2"
|
||||
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('g.nMoreKeybindings', {
|
||||
count: slotProps.data.keybindings.length - 2
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
<KeybindingList
|
||||
:keybindings="slotProps.data.keybindings"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:style="{ width: '16%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
<span class="block truncate" :title="slotProps.data.source">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<Column
|
||||
field="actions"
|
||||
header=""
|
||||
:style="{ width: '9rem' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row justify-end">
|
||||
<div
|
||||
class="actions flex flex-row justify-end whitespace-nowrap"
|
||||
>
|
||||
<Button
|
||||
v-if="slotProps.data.keybindings.length === 1"
|
||||
v-tooltip="$t('g.edit')"
|
||||
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingList from './keybinding/KeybindingList.vue'
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeybindingList from './KeybindingList.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
nMoreKeybindings: '+ {count} more',
|
||||
nMoreKeybindingsCompact: '+ {count}',
|
||||
keybindingListAriaLabel: 'Keybindings: {combos}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeKeybinding(key: string, ctrl = false, shift = false) {
|
||||
return new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key, ctrl, shift }
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(props: {
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}) {
|
||||
return render(KeybindingList, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('KeybindingList', () => {
|
||||
it('renders "-" placeholder when there are no keybindings', () => {
|
||||
renderList({ keybindings: [] })
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a single keybinding without any "more" badge', () => {
|
||||
renderList({ keybindings: [makeKeybinding('A', true)] })
|
||||
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-medium')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-compact')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 1')
|
||||
})
|
||||
|
||||
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
|
||||
renderList({
|
||||
keybindings: [
|
||||
makeKeybinding('A', true),
|
||||
makeKeybinding('B', true),
|
||||
makeKeybinding('C', true)
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 2 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 2')
|
||||
})
|
||||
|
||||
it('uses a container query parent so the visible tier can adapt to width', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
expect(screen.getByTestId('keybinding-list').className).toContain(
|
||||
'@container/keybindings'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits an accessible label listing all combos', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
|
||||
})
|
||||
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
|
||||
expect(ariaText).toContain('Keybindings:')
|
||||
expect(ariaText).toContain('Ctrl')
|
||||
expect(ariaText).toContain('A')
|
||||
expect(ariaText).toContain('Shift')
|
||||
expect(ariaText).toContain('B')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="keybindings.length > 0"
|
||||
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
|
||||
data-testid="keybinding-list"
|
||||
>
|
||||
<KeyComboDisplay
|
||||
:key-combo="keybindings[0].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
<template v-if="keybindings.length >= 2">
|
||||
<span
|
||||
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<KeyComboDisplay
|
||||
class="hidden @[16rem]/keybindings:inline-flex"
|
||||
:key-combo="keybindings[1].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="keybindings.length > 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
|
||||
data-testid="keybinding-list-more-wide"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-medium"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-compact"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span class="sr-only" data-testid="keybinding-list-aria">
|
||||
{{ ariaLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeyComboDisplay from './KeyComboDisplay.vue'
|
||||
|
||||
const { keybindings, isModified = false } = defineProps<{
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
if (keybindings.length === 0) return ''
|
||||
const combos = keybindings
|
||||
.map((binding) => binding.combo.toString())
|
||||
.join(', ')
|
||||
return t('g.keybindingListAriaLabel', { combos })
|
||||
})
|
||||
</script>
|
||||
180
src/components/error/ErrorOverlay.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ErrorOverlay from './ErrorOverlay.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
useNodeErrorFlagSync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: false,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: vi.fn((id: string) => id),
|
||||
getActiveGraphNodeIds: vi.fn(() => new Set()),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
const mockOpenPanel = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({ openPanel: mockOpenPanel })
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
linearMode: false,
|
||||
canvas: null,
|
||||
currentGraph: null,
|
||||
updateSelectedItems: vi.fn()
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
function createTestI18n() {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
close: 'Close',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.',
|
||||
viewDetails: 'View details'
|
||||
},
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeNodeError(messages: string[]): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: messages.map((message) => ({
|
||||
type: 'execution_error',
|
||||
message,
|
||||
details: 'details'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverlay(props: { appMode?: boolean } = {}) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
return render(ErrorOverlay, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ErrorOverlay', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockOpenPanel.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.canvas = null
|
||||
mockCanvasStore.currentGraph = null
|
||||
mockCanvasStore.updateSelectedItems.mockClear()
|
||||
})
|
||||
|
||||
it('renders a single overlay message without list markup', async () => {
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'Only error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Only error')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('1 ERROR')
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'View details'
|
||||
)
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app mode button label', async () => {
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'Only error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'Show errors in graph'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,15 @@
|
||||
>
|
||||
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ errorCountLabel }}
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -28,20 +28,11 @@
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3" data-testid="error-overlay-messages">
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="(message, idx) in overlayMessages"
|
||||
:key="idx"
|
||||
class="flex min-w-0 items-baseline gap-2 text-sm/snug text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
<span class="line-clamp-3 wrap-break-word whitespace-pre-wrap">{{
|
||||
message
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -60,7 +51,11 @@
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{ appMode ? t('linearMode.error.goto') : seeErrorsLabel }}
|
||||
{{
|
||||
appMode
|
||||
? t('linearMode.error.goto')
|
||||
: t('errorOverlay.viewDetails')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,73 +64,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups(ref(''))
|
||||
|
||||
const singleErrorType = computed(() => {
|
||||
const types = new Set(allErrorGroups.value.map((g) => g.type))
|
||||
return types.size === 1 ? [...types][0] : null
|
||||
})
|
||||
|
||||
const overlayMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type === 'execution') {
|
||||
// TODO(FE-816 overlay-redesign): Keep runtime overlay copy raw until the
|
||||
// overlay redesign decides how to use catalog toast fields.
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.add(group.displayMessage ?? group.displayTitle)
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
const seeErrorsLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
missing_node: t('errorOverlay.showMissingNodes'),
|
||||
missing_model: t('errorOverlay.showMissingModels'),
|
||||
swap_nodes: t('errorOverlay.showSwapNodes'),
|
||||
missing_media: t('errorOverlay.showMissingMedia')
|
||||
}
|
||||
if (singleErrorType.value) {
|
||||
return labelMap[singleErrorType.value] ?? t('errorOverlay.seeErrors')
|
||||
}
|
||||
return t('errorOverlay.seeErrors')
|
||||
})
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
|
||||
)
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
|
||||
337
src/components/error/useErrorOverlayState.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorOverlayState } from './useErrorOverlayState'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
useNodeErrorFlagSync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: false,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: vi.fn((id: string) => id),
|
||||
getActiveGraphNodeIds: vi.fn(() => new Set()),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
function createTestI18n() {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeNodeError(messages: string[]): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: messages.map((message) => ({
|
||||
type: 'execution_error',
|
||||
message,
|
||||
details: 'details'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function mountOverlayState() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
return useErrorOverlayState()
|
||||
},
|
||||
template: `
|
||||
<section>
|
||||
<span data-testid="visible">{{ isVisible }}</span>
|
||||
<span data-testid="title">{{ overlayTitle }}</span>
|
||||
<span data-testid="message">{{ overlayMessage }}</span>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [pinia, createTestI18n()]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useErrorOverlayState', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
})
|
||||
|
||||
it('uses the raw message for a single uncataloged execution error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'Only error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Execution failed')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent('Only error')
|
||||
})
|
||||
|
||||
it('uses toast copy for a single validation error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Required input is missing',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input is missing',
|
||||
toastTitle: 'Required input missing',
|
||||
toastMessage: 'KSampler is missing a required input: model'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent(
|
||||
'Required input missing'
|
||||
)
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses display copy before raw copy when toast copy is absent', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Friendly validation title',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'Raw validation error',
|
||||
displayMessage: 'Friendly validation message'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent(
|
||||
'Friendly validation title'
|
||||
)
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Friendly validation message'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses toast copy for a single runtime error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Generation failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'torch.OutOfMemoryError: CUDA out of memory',
|
||||
toastTitle: 'Generation failed',
|
||||
toastMessage:
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Generation failed')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group toast copy for a single missing media error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
missingMediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'image.png',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Media input missing')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Load Image is missing a required media file.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show when a raw error has no resolved overlay message', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('uses aggregate copy for multiple errors', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError([
|
||||
'First error',
|
||||
'Second error',
|
||||
'Third error',
|
||||
'Fourth error',
|
||||
'Fifth error',
|
||||
'Sixth error',
|
||||
'Seventh error'
|
||||
])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [{ message: 'First error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
})
|
||||
})
|
||||
103
src/components/error/useErrorOverlayState.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
|
||||
function resolveSingleOverlayCopy(
|
||||
group: ErrorGroup
|
||||
): { title?: string; message: string } | undefined {
|
||||
if (group.type === 'execution') {
|
||||
const [card] = group.cards
|
||||
const [error] = card?.errors ?? []
|
||||
const message =
|
||||
error?.toastMessage ??
|
||||
error?.displayMessage ??
|
||||
error?.message ??
|
||||
group.displayMessage ??
|
||||
group.displayTitle
|
||||
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: error?.toastTitle ?? error?.displayTitle ?? group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
group.toastMessage ?? group.displayMessage ?? group.displayTitle
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: group.toastTitle ?? group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
export function useErrorOverlayState() {
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { totalErrorCount, isErrorOverlayOpen } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
|
||||
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
|
||||
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
|
||||
const singleErrorGroup = computed(() =>
|
||||
hasExactlyOneError.value && allErrorGroups.value.length === 1
|
||||
? allErrorGroups.value[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const multipleErrorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.multipleErrorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const singleOverlayCopy = computed(() =>
|
||||
singleErrorGroup.value
|
||||
? resolveSingleOverlayCopy(singleErrorGroup.value)
|
||||
: undefined
|
||||
)
|
||||
|
||||
const overlayMessage = computed(() => {
|
||||
if (hasMultipleErrors.value) {
|
||||
return t('errorOverlay.multipleErrorsMessage')
|
||||
}
|
||||
|
||||
return singleOverlayCopy.value?.message ?? ''
|
||||
})
|
||||
|
||||
const overlayTitle = computed(() =>
|
||||
hasMultipleErrors.value
|
||||
? multipleErrorCountLabel.value
|
||||
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
isErrorOverlayOpen.value &&
|
||||
totalErrorCount.value > 0 &&
|
||||
overlayMessage.value.trim().length > 0
|
||||
)
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
overlayMessage,
|
||||
overlayTitle
|
||||
}
|
||||
}
|
||||