mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 16:54:51 +00:00
Compare commits
39 Commits
feature/dr
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
413d082f90 | ||
|
|
3400438dfb | ||
|
|
3088754bb8 | ||
|
|
f076106ca5 | ||
|
|
d7fa853c06 | ||
|
|
07f881fc14 | ||
|
|
e14b5c6f3f | ||
|
|
065650b3bf | ||
|
|
a58f927871 | ||
|
|
c304f206a6 | ||
|
|
75ea646090 | ||
|
|
a670944a05 | ||
|
|
282b8cf819 | ||
|
|
70bc8dc6e6 | ||
|
|
ac56ecf009 | ||
|
|
e97cca9e4a | ||
|
|
49a7b7b558 | ||
|
|
8d82944441 | ||
|
|
a0f4feb111 | ||
|
|
d4be483c03 | ||
|
|
8d0b21e9e8 | ||
|
|
69858538d0 | ||
|
|
001b132b0c | ||
|
|
56b05c0fd5 | ||
|
|
403353ac77 | ||
|
|
c4db198875 | ||
|
|
040e490f02 | ||
|
|
90c523b4a3 | ||
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 | ||
|
|
c8b5589768 | ||
|
|
b4b95980da | ||
|
|
8c0af36c4a | ||
|
|
78a8d6f8fc | ||
|
|
cc41e3e1ac | ||
|
|
f994673dd1 | ||
|
|
444dc3fccd | ||
|
|
ed028a88be |
@@ -15,6 +15,11 @@ reviews:
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
# Explicitly disable the built-in docstring coverage check, which is
|
||||
# enabled via organization-level settings. This repo opts out at the
|
||||
# repo level without affecting other org repos.
|
||||
docstrings:
|
||||
mode: 'off'
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
|
||||
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -85,6 +85,16 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
4
.github/workflows/release-draft-create.yaml
vendored
4
.github/workflows/release-draft-create.yaml
vendored
@@ -92,9 +92,7 @@ jobs:
|
||||
make_latest: >-
|
||||
${{ github.event.pull_request.base.ref == 'main' &&
|
||||
needs.build.outputs.is_prerelease == 'false' }}
|
||||
draft: >-
|
||||
${{ github.event.pull_request.base.ref != 'main' ||
|
||||
needs.build.outputs.is_prerelease == 'true' }}
|
||||
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
prerelease: >-
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -78,6 +78,11 @@ const config: StorybookConfig = {
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/billing/useBillingContext',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -42,7 +41,6 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { drops } from '../src/data/drops'
|
||||
import type { Locale } from '../src/i18n/translations'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH_EN = '/drops'
|
||||
const PATH_ZH = '/zh-CN/drops'
|
||||
const CLOUD_URL = 'https://cloud.comfy.org'
|
||||
|
||||
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
|
||||
[PATH_EN, 'en'],
|
||||
[PATH_ZH, 'zh-CN']
|
||||
]
|
||||
|
||||
function heroSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('drops.hero.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ctaSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('drops.cta.heading', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function dropsSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('drops.section.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Drops landing — desktop @smoke', () => {
|
||||
test('renders the configured title at /drops', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page).toHaveTitle(t('drops.page.title', 'en'))
|
||||
})
|
||||
|
||||
test('renders the localized title at /zh-CN/drops', async ({ page }) => {
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page).toHaveTitle(t('drops.page.title', 'zh-CN'))
|
||||
})
|
||||
|
||||
test('is indexable at both locales', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('hero h1 renders the localized title in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('drops.hero.title', 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('drops.hero.title', 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('hero primary CTA links to /download per locale', async ({ page }) => {
|
||||
for (const [path, locale, expectedHref] of [
|
||||
[PATH_EN, 'en', '/download'],
|
||||
[PATH_ZH, 'zh-CN', '/zh-CN/download']
|
||||
] as const) {
|
||||
await page.goto(path)
|
||||
const primary = heroSection(page, locale).getByRole('link', {
|
||||
name: t('drops.hero.primary', locale)
|
||||
})
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', expectedHref)
|
||||
}
|
||||
})
|
||||
|
||||
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const secondary = heroSection(page, locale).getByRole('link', {
|
||||
name: t('drops.hero.secondary', locale)
|
||||
})
|
||||
await expect(secondary).toBeVisible()
|
||||
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
|
||||
await expect(secondary).toHaveAttribute('target', '_blank')
|
||||
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('subscribe banner shows text and a sign-up link in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
await expect(page.getByText(t('drops.banner.text', locale))).toBeVisible()
|
||||
|
||||
const signUp = page.getByRole('link', {
|
||||
name: t('drops.banner.cta', locale)
|
||||
})
|
||||
await expect(signUp).toBeVisible()
|
||||
await expect(signUp).toHaveAttribute('href', externalLinks.youtube)
|
||||
await expect(signUp).toHaveAttribute('target', '_blank')
|
||||
await expect(signUp).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('closing CTA shows heading and both action buttons in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const section = ctaSection(page, locale)
|
||||
await expect(
|
||||
section.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('drops.cta.heading', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const primary = section.getByRole('link', {
|
||||
name: t('drops.cta.primary', locale)
|
||||
})
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
|
||||
await expect(primary).toHaveAttribute('target', '_blank')
|
||||
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const secondary = section.getByRole('link', {
|
||||
name: t('drops.cta.secondary', locale)
|
||||
})
|
||||
await expect(secondary).toBeVisible()
|
||||
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
|
||||
await expect(secondary).toHaveAttribute('target', '_blank')
|
||||
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const section = dropsSection(page, locale)
|
||||
|
||||
await expect(
|
||||
section.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('drops.section.title', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const cards = section.locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
for (const [i, drop] of drops.entries()) {
|
||||
const card = cards.nth(i)
|
||||
await expect(card).toContainText(drop.title[locale])
|
||||
const explore = card.getByRole('link', {
|
||||
name: drop.cta.label[locale]
|
||||
})
|
||||
await expect(explore).toBeVisible()
|
||||
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop: first 4 drop cards are wider than cards 5+', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
|
||||
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
|
||||
expect(firstWidth).toBeGreaterThan(fifthWidth)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Drops landing — mobile @mobile', () => {
|
||||
test('drops grid stacks in a single column at mobile width', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
const viewportWidth = page.viewportSize()!.width
|
||||
const firstBox = await cards.nth(0).boundingBox()
|
||||
const secondBox = await cards.nth(1).boundingBox()
|
||||
expect(firstBox, 'first card bounding box').not.toBeNull()
|
||||
expect(secondBox, 'second card bounding box').not.toBeNull()
|
||||
expect(firstBox!.width).toBeGreaterThanOrEqual(viewportWidth * 0.7)
|
||||
expect(secondBox!.y).toBeGreaterThanOrEqual(firstBox!.y + firstBox!.height)
|
||||
})
|
||||
|
||||
test('closing CTA heading stays within viewport width', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
const heading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('drops.cta.heading', 'en')
|
||||
})
|
||||
await heading.scrollIntoViewIfNeeded()
|
||||
await expect(heading).toBeVisible()
|
||||
|
||||
const box = await heading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
@@ -16,11 +12,10 @@ type TermsLink = {
|
||||
href: string
|
||||
}
|
||||
|
||||
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
termsLink?: TermsLink
|
||||
termsLink: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -29,37 +24,23 @@ const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="resolveRel(primaryCta)"
|
||||
variant="default"
|
||||
size="lg"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="resolveRel(secondaryCta)"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
|
||||
<a
|
||||
v-if="termsLink"
|
||||
:href="termsLink.href"
|
||||
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
|
||||
@@ -30,9 +30,9 @@ function toggle(index: number) {
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
@@ -83,7 +83,7 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type Visual =
|
||||
| {
|
||||
type: 'image'
|
||||
src: string
|
||||
alt: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
alt: string
|
||||
poster?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const {
|
||||
visual,
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
youtubeVideoId,
|
||||
startDateTime,
|
||||
endDateTime
|
||||
} = defineProps<{
|
||||
visual?: Visual
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
youtubeVideoId: string
|
||||
startDateTime: string
|
||||
endDateTime: string
|
||||
}>()
|
||||
|
||||
const embedUrl = computed(
|
||||
() =>
|
||||
`https://www.youtube-nocookie.com/embed/${youtubeVideoId}?autoplay=1&mute=1&rel=0`
|
||||
)
|
||||
|
||||
// Keep SSR/initial paint deterministic on the logo and only flip to the embed
|
||||
// after client hydration — avoids a build-time `now` leaking into the markup.
|
||||
const mounted = ref(false)
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
})
|
||||
|
||||
const now = useNow({ interval: 30_000 })
|
||||
const startMs = computed(() => new Date(startDateTime).getTime())
|
||||
const endMs = computed(() => new Date(endDateTime).getTime())
|
||||
|
||||
const isLive = computed(
|
||||
() =>
|
||||
mounted.value &&
|
||||
now.value.getTime() >= startMs.value &&
|
||||
now.value.getTime() < endMs.value
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<div
|
||||
v-if="isLive"
|
||||
class="mb-10 aspect-video w-full overflow-hidden rounded-2xl lg:mb-12"
|
||||
>
|
||||
<iframe
|
||||
:src="embedUrl"
|
||||
:title="title"
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="visual?.type === 'image'"
|
||||
:src="visual.src"
|
||||
:alt="visual.alt"
|
||||
:width="visual.width"
|
||||
:height="visual.height"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-lg"
|
||||
/>
|
||||
<video
|
||||
v-else-if="visual?.type === 'video'"
|
||||
:src="visual.src"
|
||||
:poster="visual.poster"
|
||||
:aria-label="visual.alt"
|
||||
:width="visual.width"
|
||||
:height="visual.height"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-2xl"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="eyebrow"
|
||||
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-2xl text-base text-primary-comfy-canvas/70 lg:text-lg"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="resolveRel(primaryCta)"
|
||||
size="lg"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="resolveRel(secondaryCta)"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<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"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Drop } from '../../data/drops'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
|
||||
import ButtonPill from '../ui/button-pill/ButtonPill.vue'
|
||||
import Card from '../ui/card/Card.vue'
|
||||
import CardContent from '../ui/card/CardContent.vue'
|
||||
import CardDescription from '../ui/card/CardDescription.vue'
|
||||
import CardFooter from '../ui/card/CardFooter.vue'
|
||||
import CardHeader from '../ui/card/CardHeader.vue'
|
||||
import CardTitle from '../ui/card/CardTitle.vue'
|
||||
|
||||
const { drop, locale } = defineProps<{
|
||||
drop: Drop
|
||||
locale: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="group/pill-trigger relative h-full overflow-hidden">
|
||||
<a
|
||||
:href="drop.cta.href[locale]"
|
||||
:aria-label="`${drop.title[locale]} — ${drop.cta.label[locale]}`"
|
||||
class="rounded-4.5xl focus-visible:ring-primary-comfy-yellow absolute inset-0 z-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col-reverse">
|
||||
<CardHeader class="gap-2 px-6">
|
||||
<Badge variant="ghost">
|
||||
{{ drop.category[locale] }}
|
||||
</Badge>
|
||||
<CardTitle class="pt-4">
|
||||
{{ drop.title[locale] }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ drop.description[locale] }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="relative p-2">
|
||||
<div class="aspect-video w-full overflow-hidden rounded-4xl">
|
||||
<img
|
||||
v-if="drop.media.type === 'image'"
|
||||
:src="drop.media.src"
|
||||
:alt="drop.media.alt[locale]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="drop.media.src"
|
||||
:poster="drop.media.poster"
|
||||
:aria-label="drop.media.alt[locale]"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
|
||||
{{ drop.badge[locale] }}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<CardFooter class="mt-auto px-6 pb-6">
|
||||
<ButtonPill as="span" variant="ghost" icon-position="left">
|
||||
{{ drop.cta.label[locale] }}
|
||||
</ButtonPill>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
<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"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,10 +66,10 @@ const {
|
||||
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"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
|
||||
@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
<template>
|
||||
<footer
|
||||
ref="footerRef"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
|
||||
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
|
||||
>
|
||||
<div
|
||||
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"
|
||||
|
||||
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
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"
|
||||
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ size, variant }), className)"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
|
||||
@@ -4,16 +4,15 @@ import { cva } from 'cva'
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
ghost: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,8 +8,7 @@ export const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-8 px-4 py-2 text-xs md:text-sm',
|
||||
default: 'h-10 px-6 py-2.5 text-xs md:text-sm',
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
@@ -17,7 +16,7 @@ export const buttonVariants = cva(
|
||||
'bg-primary-comfy-yellow hover:bg-primary-comfy-yellow/90 text-primary-comfy-ink uppercase',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
|
||||
link: "text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 text-primary-warm-white rounded-4.5xl flex flex-col gap-6 shadow-sm',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-content" :class="cn(className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-description"
|
||||
:class="
|
||||
cn('line-clamp-3 text-base text-primary-comfy-canvas/70', className)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-footer" :class="cn('flex items-center', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-header" :class="cn('flex flex-col gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-title"
|
||||
:class="
|
||||
cn(
|
||||
'text-xl leading-none font-medium text-primary-comfy-canvas md:text-2xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,6 @@ const baseRoutes = {
|
||||
cloudEnterprise: '/cloud/enterprise',
|
||||
api: '/api',
|
||||
gallery: '/gallery',
|
||||
drops: '/drops',
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
// Image URLs are placeholders at media.comfy.org/website/drops/<id>.png —
|
||||
// asset uploads and native zh-CN review are pending follow-ups (see
|
||||
// apps/website/.scratch/drops-page/PRD.md).
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
type DropMedia =
|
||||
| { type: 'image'; src: string; alt: LocalizedText }
|
||||
| { type: 'video'; src: string; alt: LocalizedText; poster?: string }
|
||||
|
||||
export type Drop = {
|
||||
id: string
|
||||
badge?: LocalizedText
|
||||
category: LocalizedText
|
||||
media: DropMedia
|
||||
title: LocalizedText
|
||||
description: LocalizedText
|
||||
cta: { label: LocalizedText; href: LocalizedText }
|
||||
}
|
||||
|
||||
const EXPLORE: LocalizedText = { en: 'EXPLORE', 'zh-CN': '探索' }
|
||||
const PLATFORM: LocalizedText = { en: 'Platform', 'zh-CN': '平台' }
|
||||
const CLOUD: LocalizedText = { en: 'Cloud', 'zh-CN': '云端' }
|
||||
const COMMUNITY: LocalizedText = { en: 'Community', 'zh-CN': '社区' }
|
||||
const DEVELOPER: LocalizedText = { en: 'Developer', 'zh-CN': '开发者' }
|
||||
const MODELS_AND_NODES: LocalizedText = {
|
||||
en: 'Models & Nodes',
|
||||
'zh-CN': '模型与节点'
|
||||
}
|
||||
const NEW_BADGE: LocalizedText = { en: 'NEW', 'zh-CN': '新' }
|
||||
const FEATURED_BADGE: LocalizedText = { en: 'FEATURED', 'zh-CN': '精选' }
|
||||
|
||||
function imageFor(fileName: string, alt: LocalizedText): DropMedia {
|
||||
return {
|
||||
type: 'image',
|
||||
src: `https://media.comfy.org/website/drops/${fileName}`,
|
||||
alt
|
||||
}
|
||||
}
|
||||
|
||||
function videoFor(
|
||||
fileName: string,
|
||||
alt: LocalizedText,
|
||||
poster?: string
|
||||
): DropMedia {
|
||||
return {
|
||||
type: 'video',
|
||||
src: `https://media.comfy.org/website/drops/${fileName}`,
|
||||
alt,
|
||||
...(poster && {
|
||||
poster: `https://media.comfy.org/website/drops/${poster}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const drops: readonly Drop[] = [
|
||||
{
|
||||
id: 'desktop-client',
|
||||
badge: NEW_BADGE,
|
||||
category: PLATFORM,
|
||||
media: imageFor('Drops_2x2card_Desktop.jpg', {
|
||||
en: 'New Desktop Client',
|
||||
'zh-CN': '新桌面客户端'
|
||||
}),
|
||||
title: { en: 'New Desktop Client', 'zh-CN': '新桌面客户端' },
|
||||
description: {
|
||||
en: 'A faster, redesigned desktop app for ComfyUI — one-click install and managed updates.',
|
||||
'zh-CN': '更快、重新设计的 ComfyUI 桌面应用程序 — 一键安装与受管更新。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
category: PLATFORM,
|
||||
media: videoFor('Drops_2x2card_APP.mp4', {
|
||||
en: 'App Mode',
|
||||
'zh-CN': 'App 模式'
|
||||
}),
|
||||
title: { en: 'App Mode', 'zh-CN': 'App 模式' },
|
||||
description: {
|
||||
en: 'A simplified view of your workflows. Flip back to the node graph anytime to go deeper.',
|
||||
'zh-CN': '工作流的简化视图。随时切换回节点图视图以深入了解。'
|
||||
},
|
||||
// TODO: no destination page yet — link out when App Mode lands.
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: {
|
||||
en: 'https://docs.comfy.org/interface/app-mode',
|
||||
'zh-CN': 'https://docs.comfy.org/zh/interface/app-mode'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-api',
|
||||
badge: NEW_BADGE,
|
||||
category: DEVELOPER,
|
||||
media: imageFor('Drops_2x2card_API.jpg', {
|
||||
en: 'Comfy API',
|
||||
'zh-CN': 'Comfy API'
|
||||
}),
|
||||
title: { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
description: {
|
||||
en: 'Turn any workflow into a production endpoint. Automate generation and scale to thousands of outputs.',
|
||||
'zh-CN': '将任意工作流变成生产端点。自动化生成并扩展到数千个输出。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
// TODO: production MCP copy + destination page pending.
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
media: imageFor('Drops_3x3card_Comm Workflows.jpg', {
|
||||
en: 'Community Workflows',
|
||||
'zh-CN': '社区工作流'
|
||||
}),
|
||||
title: {
|
||||
en: 'Community Workflows',
|
||||
'zh-CN': '社区工作流'
|
||||
},
|
||||
description: {
|
||||
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template.',
|
||||
'zh-CN': '浏览和混搭数千个社区共享的工作流。从经过验证的模板开始。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.workflows, 'zh-CN': externalLinks.workflows }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supported-models',
|
||||
category: MODELS_AND_NODES,
|
||||
media: imageFor('Drops_Supported models.jpg', {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
}),
|
||||
title: { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
description: {
|
||||
en: 'Run the latest open and partner models — every checkpoint, LoRA, and ControlNet, ready to use in your graph.',
|
||||
'zh-CN':
|
||||
'运行最新的开源和合作伙伴模型 — 每个 checkpoint、LoRA 和 ControlNet 都可直接在工作流中使用。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/p/supported-models', 'zh-CN': '/zh-CN/p/supported-models' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supported-nodes',
|
||||
category: MODELS_AND_NODES,
|
||||
media: videoFor('Drops_3x3card_supported nodes.mp4', {
|
||||
en: 'Supported Nodes',
|
||||
'zh-CN': '支持的节点'
|
||||
}),
|
||||
title: { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
description: {
|
||||
en: 'Thousands of community and partner nodes, curated and verified to run on Comfy Cloud.',
|
||||
'zh-CN':
|
||||
'数千个社区与合作伙伴节点,经过精选与验证,可在 Comfy Cloud 上运行。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: {
|
||||
en: '/cloud/supported-nodes',
|
||||
'zh-CN': '/zh-CN/cloud/supported-nodes'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-enterprise',
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_3x3card_enterprise.png', {
|
||||
en: 'Comfy Enterprise',
|
||||
'zh-CN': 'Comfy 企业版'
|
||||
}),
|
||||
title: { en: 'Comfy Enterprise', 'zh-CN': 'Comfy 企业版' },
|
||||
description: {
|
||||
en: 'Enterprise-grade infrastructure for the creative engine inside your organization.',
|
||||
'zh-CN': '为您组织内创意引擎提供的企业级基础设施。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/cloud/enterprise', 'zh-CN': '/zh-CN/cloud/enterprise' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'learning-hub',
|
||||
category: COMMUNITY,
|
||||
media: imageFor('Drops_3x3_Learninghub.jpg', {
|
||||
en: 'Learning Hub',
|
||||
'zh-CN': '学习中心'
|
||||
}),
|
||||
title: { en: 'Learning Hub', 'zh-CN': '学习中心' },
|
||||
description: {
|
||||
en: 'Walkthroughs and ready-to-run workflows to take you from first render to production pipeline.',
|
||||
'zh-CN': '配套教程与开箱即用的工作流,带您从第一次渲染走向生产管线。'
|
||||
},
|
||||
cta: {
|
||||
label: { en: 'START LEARNING', 'zh-CN': '开始学习' },
|
||||
href: { en: '/learning', 'zh-CN': '/zh-CN/learning' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'share-comfy',
|
||||
badge: NEW_BADGE,
|
||||
category: COMMUNITY,
|
||||
media: videoFor('Drops_3x3card_Affilliate.mp4', {
|
||||
en: 'Comfy Affiliate',
|
||||
'zh-CN': 'Comfy Affiliate'
|
||||
}),
|
||||
title: {
|
||||
en: 'Comfy Affiliate',
|
||||
'zh-CN': 'Comfy Affiliate'
|
||||
},
|
||||
description: {
|
||||
en: 'Share Comfy with your audience and earn for every creator you bring on board.',
|
||||
'zh-CN': '与您的受众分享 Comfy,为您带来的每一位创作者获得回报。'
|
||||
},
|
||||
// /affiliates is locale-invariant: same URL in both locales.
|
||||
cta: {
|
||||
label: { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
href: { en: '/affiliates', 'zh-CN': '/affiliates' }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -4928,70 +4928,6 @@ const translations = {
|
||||
'affiliate.cta.termsLabel': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': '阅读联盟计划条款'
|
||||
},
|
||||
|
||||
// Drops page (/drops) — head metadata
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'drops.page.title': {
|
||||
en: 'Drops — Everything new in ComfyUI',
|
||||
'zh-CN': 'Drops — ComfyUI 最新动态'
|
||||
},
|
||||
'drops.page.description': {
|
||||
en: 'Explore everything new in Comfy — releases, features, models, and resources across platform, cloud, community, and developer tools.',
|
||||
'zh-CN':
|
||||
'探索 Comfy 的最新动态 — 涵盖平台、云端、社区和开发者工具的发布、功能、模型和资源。'
|
||||
},
|
||||
|
||||
// Drops page (/drops) — hero section
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'drops.hero.title': {
|
||||
en: 'Everything new in ComfyUI',
|
||||
'zh-CN': 'ComfyUI 全新内容'
|
||||
},
|
||||
'drops.hero.primary': {
|
||||
en: 'Download Desktop',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'drops.hero.secondary': {
|
||||
en: 'Launch Cloud',
|
||||
'zh-CN': '启动云端'
|
||||
},
|
||||
'drops.hero.visualAlt': {
|
||||
en: 'Comfy',
|
||||
'zh-CN': 'Comfy'
|
||||
},
|
||||
|
||||
// Drops page (/drops) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'drops.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
},
|
||||
'drops.banner.cta': {
|
||||
en: 'Sign up now',
|
||||
'zh-CN': '立即注册'
|
||||
},
|
||||
|
||||
// Drops page (/drops) — closing CTA
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'drops.cta.heading': {
|
||||
en: 'Everything Comfy ships. All in one place.',
|
||||
'zh-CN': 'Comfy 的全部内容,一处尽享。'
|
||||
},
|
||||
'drops.cta.primary': {
|
||||
en: 'Open Comfy Cloud',
|
||||
'zh-CN': '打开 Comfy Cloud'
|
||||
},
|
||||
'drops.cta.secondary': {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
|
||||
// Drops page (/drops) — drops grid
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'drops.section.title': {
|
||||
en: 'Latest Drops',
|
||||
'zh-CN': '最新发布'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('drops.page.title', locale)}
|
||||
description={t('drops.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
</BaseLayout>
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('drops.page.title', locale)}
|
||||
description={t('drops.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
</BaseLayout>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CtaCenter01
|
||||
:heading="t('drops.cta.heading', locale)"
|
||||
:primary-cta="{
|
||||
label: t('drops.cta.primary', locale),
|
||||
href: externalLinks.cloud,
|
||||
target: '_blank'
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('drops.cta.secondary', locale),
|
||||
href: externalLinks.workflows,
|
||||
target: '_blank'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import DropCard from '../../components/common/DropCard.vue'
|
||||
import { drops } from '../../data/drops'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-warm-white text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('drops.section.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-6 lg:mt-12">
|
||||
<div
|
||||
v-for="(drop, index) in drops"
|
||||
:key="drop.id"
|
||||
:class="index < 4 ? 'md:col-span-3' : 'md:col-span-2'"
|
||||
>
|
||||
<DropCard :drop :locale />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import HeroLivestream01 from '../../components/blocks/HeroLivestream01.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
// TODO(drops-livestream): replace with the production stream ID + window.
|
||||
const youtubeVideoId = 'nlLZfNIqF8M'
|
||||
const startDateTime = '2026-06-29T15:00:00Z'
|
||||
const endDateTime = '2026-06-29T17:15:00Z'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroLivestream01
|
||||
:visual="{
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/drops/Drops_hero_rotatinglogo.webm',
|
||||
alt: t('drops.hero.visualAlt', locale),
|
||||
width: 1760,
|
||||
height: 528
|
||||
}"
|
||||
:title="t('drops.hero.title', locale)"
|
||||
:primary-cta="{
|
||||
label: t('drops.hero.primary', locale),
|
||||
href: routes.download
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('drops.hero.secondary', locale),
|
||||
href: externalLinks.cloud,
|
||||
target: '_blank'
|
||||
}"
|
||||
:youtube-video-id="youtubeVideoId"
|
||||
:start-date-time="startDateTime"
|
||||
:end-date-time="endDateTime"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = 'https://luma.com/l7c5z4gp'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">{{ t('drops.banner.text', locale) }}</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="link"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('drops.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
export function resolveRel(cta: {
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
}): AnchorHTMLAttributes['rel'] {
|
||||
return (
|
||||
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -231,6 +231,22 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_interrupted` WS event (user-initiated stop). */
|
||||
executionInterrupted(jobId: string, nodeId: string): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_interrupted',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
executed: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
|
||||
@@ -69,6 +69,24 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(customTemplates),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
|
||||
@@ -38,7 +38,6 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
|
||||
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('preserves share auth attribution before redirecting logged-out users', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => sessionStorage.getItem(key),
|
||||
SHARE_AUTH_STORAGE_KEY
|
||||
)
|
||||
)
|
||||
.toBe(JSON.stringify({ share: 'abc' }))
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
|
||||
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
const dialogs = page.getByRole('dialog')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
if ((await dialogs.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -32,6 +32,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await dialog.getByTestId('pointer-zone').hover()
|
||||
await dialog.getByText('Brush Settings').hover()
|
||||
await expect(dialog.getByTestId('brush-cursor')).toHaveCSS('opacity', '0')
|
||||
|
||||
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
@@ -250,21 +254,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -284,20 +275,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 324 KiB |
@@ -34,19 +34,17 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
if (!observedContentType) {
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
}
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
@@ -69,24 +67,11 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
@@ -95,7 +80,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
}
|
||||
|
||||
async function setLocaleAndWaitForWorkflowReload(
|
||||
comfyPage: ComfyPage,
|
||||
locale: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(async (targetLocale) => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('No active workflow while waiting for locale reload')
|
||||
}
|
||||
|
||||
const changeTracker = workflow.changeTracker.constructor as unknown as {
|
||||
isLoadingGraph: boolean
|
||||
}
|
||||
|
||||
let sawLoading = false
|
||||
const waitForReload = new Promise<void>((resolve, reject) => {
|
||||
const timeoutAt = performance.now() + 5000
|
||||
|
||||
const tick = () => {
|
||||
if (changeTracker.isLoadingGraph) {
|
||||
sawLoading = true
|
||||
}
|
||||
|
||||
if (sawLoading && !changeTracker.isLoadingGraph) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (performance.now() > timeoutAt) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
})
|
||||
|
||||
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
|
||||
await waitForReload
|
||||
}, locale)
|
||||
}
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
test.describe('Locale-specific documentation', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
@@ -434,9 +382,7 @@ This is English documentation.
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
} finally {
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
} from '@e2e/fixtures/utils/painter'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
const HIDDEN_PAINTER_WIDGET_NAMES = ['width', 'height', 'bg_color'] as const
|
||||
const HIDDEN_PAINTER_NUMBER_WIDGET_NAMES = ['width', 'height'] as const
|
||||
|
||||
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
})
|
||||
|
||||
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
|
||||
test.describe('Widget rendering', () => {
|
||||
test('Node enforces minimum size', async ({ comfyPage }) => {
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
@@ -28,17 +31,15 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
expect(size![1]).toBeGreaterThanOrEqual(550)
|
||||
})
|
||||
|
||||
test('Width, height, and bg_color standard widgets are hidden', async ({
|
||||
test('Does not render hidden standard widgets in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hiddenFlags = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
return (node?.widgets ?? [])
|
||||
.filter((w) => ['width', 'height', 'bg_color'].includes(w.name))
|
||||
.map((w) => w.options.hidden ?? false)
|
||||
})
|
||||
expect(hiddenFlags).toEqual([true, true, true])
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
for (const widgetName of HIDDEN_PAINTER_WIDGET_NAMES) {
|
||||
await expect(node.getByLabel(widgetName, { exact: true })).toBeHidden()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -788,6 +789,49 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Painter legacy LiteGraph rendering',
|
||||
{ tag: ['@widget', '@canvas'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
})
|
||||
|
||||
test('Does not open editors for backend-hidden number widget rows in legacy LiteGraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterNodes = await comfyPage.nodeOps.getNodeRefsByType('Painter')
|
||||
expect(painterNodes).toHaveLength(1)
|
||||
const painterNode = painterNodes[0]!
|
||||
const maskWidget = await painterNode.getWidgetByName('mask')
|
||||
const maskWidgetClientPosition = await maskWidget.getPosition()
|
||||
const widgetRowClientHeight = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
(window.LiteGraph!.NODE_WIDGET_HEIGHT + 4) *
|
||||
window.app!.canvas.ds.scale
|
||||
)
|
||||
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
|
||||
for (const [
|
||||
index,
|
||||
widgetName
|
||||
] of HIDDEN_PAINTER_NUMBER_WIDGET_NAMES.entries()) {
|
||||
await test.step(`Click ${widgetName} row`, async () => {
|
||||
await comfyPage.page.mouse.click(
|
||||
maskWidgetClientPosition.x,
|
||||
maskWidgetClientPosition.y + widgetRowClientHeight * (index + 1)
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Painter — input image connection',
|
||||
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -13,10 +13,6 @@ import type {
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
// useKeyModifier('Control') needs keyboard events, not click modifiers.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
// dispatchEvent avoids the selection footer intercepting a right click.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection count sums the outputs of a stacked asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection bar stays capped, not stretched, on a wide panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const gutterBox = await gutter.boundingBox()
|
||||
if (!gutterBox) {
|
||||
throw new Error('sidebar splitter gutter has no bounding box')
|
||||
}
|
||||
await comfyPage.page.mouse.move(
|
||||
gutterBox.x + gutterBox.width / 2,
|
||||
gutterBox.y + gutterBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(520)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tab.selectionFooter.boundingBox()
|
||||
const side = await sidebar.boundingBox()
|
||||
return bar && side ? side.width - bar.width : 0
|
||||
})
|
||||
.toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
// Queue polling also calls /jobs, so wait for completed history only.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - add node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking a model defers creation until placed on the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
|
||||
test('Ghost preview shows the model in the loader widget before placing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
const test = mergeTests(createCloudAssetsFixture([]), templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
|
||||
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
|
||||
async function runOnBackgroundTab(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute
|
||||
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await comfyPage.workflow.waitForActiveWorkflow()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(topbar.getActiveTab()).toContainText('(2)')
|
||||
|
||||
const backgroundTab = topbar.getTab(0)
|
||||
exec.executionStart(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toBeVisible()
|
||||
|
||||
return { exec, jobId, backgroundTab }
|
||||
}
|
||||
|
||||
test.describe('Workflow tab status indicator', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('replaces the running indicator with completed when the job finishes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows failed when the background job errors', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
|
||||
|
||||
// The error opens a modal dialog that aria-hides the rest of the app
|
||||
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
|
||||
// so the badge is reachable by role.
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(errorDialog).toBeHidden()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Failed' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('drops the indicator on user interrupt rather than showing an error', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionInterrupted(jobId, KSAMPLER_NODE)
|
||||
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clears the indicator once the tab is activated', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
|
||||
const currentTab = comfyPage.menu.topbar.getActiveTab()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await backgroundTab.click()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
|
||||
await currentTab.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -280,3 +280,36 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Vue Node Group Context Menu',
|
||||
{ tag: ['@vue-nodes', '@canvas'] },
|
||||
() => {
|
||||
test('right-clicking a group opens the Vue context menu instead of the legacy menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Deselect so the right-click selects the group itself.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => graph!.groups.length))
|
||||
.toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y, {
|
||||
button: 'right'
|
||||
})
|
||||
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.litegraphMenu).toBeHidden()
|
||||
|
||||
// Group-only action confirms it is the group menu.
|
||||
await expect(
|
||||
comfyPage.contextMenu.primeVueMenu.getByText('Fit Group To Nodes')
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.2",
|
||||
"version": "1.47.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
|
||||
@@ -44,16 +44,32 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
key: 'renderer-default',
|
||||
title: 'Default renderer dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-escape-hatch',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dialog migration regression net: the showConfirmDialog helper must open
|
||||
* its dialog through the Reka renderer with zeroed section padding (the
|
||||
* Confirm* sections carry their own). Catches accidental reverts of the
|
||||
* Phase 6 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
|
||||
describe('showConfirmDialog Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
|
||||
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
|
||||
showConfirmDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('md')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the confirm section components and caller props', () => {
|
||||
showConfirmDialog({
|
||||
key: 'confirm-test',
|
||||
headerProps: { title: 'Title' },
|
||||
props: { promptText: 'Prompt' },
|
||||
footerProps: { confirmText: 'Delete' }
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('confirm-test')
|
||||
expect(args.headerComponent).toBe(ConfirmHeader)
|
||||
expect(args.component).toBe(ConfirmBody)
|
||||
expect(args.footerComponent).toBe(ConfirmFooter)
|
||||
expect(args.headerProps).toEqual({ title: 'Title' })
|
||||
expect(args.props).toEqual({ promptText: 'Prompt' })
|
||||
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -23,11 +26,13 @@ export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'md',
|
||||
// Confirm sections carry their own padding — zero out the dialog
|
||||
// chrome padding, like the PrimeVue `pt` overrides did.
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0',
|
||||
footerClass: 'p-0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -38,6 +39,23 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: mockWorkspaceApi
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -118,6 +136,8 @@ describe('UsageLogsTable', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -320,6 +340,20 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('renders credit_added event with correct detail template', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
|
||||
@@ -99,7 +99,10 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
EventType,
|
||||
@@ -112,6 +115,9 @@ const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
@@ -138,10 +144,13 @@ const loadEvents = async () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
})
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<NodeDragPreview />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -145,6 +147,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
@@ -464,6 +467,7 @@ useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useGroupContextMenu()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
|
||||
97
src/components/graph/NodeDragPreview.test.ts
Normal file
97
src/components/graph/NodeDragPreview.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
|
||||
() => ({
|
||||
default: { template: '<div data-testid="node-preview" />' }
|
||||
})
|
||||
)
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
|
||||
}
|
||||
|
||||
function ghostElement() {
|
||||
return document.querySelector('[data-testid="node-preview"]')?.parentElement
|
||||
?.parentElement
|
||||
}
|
||||
|
||||
describe('NodeDragPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows no ghost when nothing is being dragged', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('keeps the ghost hidden until the mouse position is known', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('follows the mouse with an offset while dragging', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
moveMouse(300, 400)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
|
||||
})
|
||||
|
||||
it('removes the ghost when the drag is cancelled', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
expect(ghostElement()).toBeTruthy()
|
||||
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
57
src/components/graph/NodeDragPreview.vue
Normal file
57
src/components/graph/NodeDragPreview.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGhost && rafPosition"
|
||||
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
|
||||
:style="{
|
||||
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview
|
||||
:node-def="draggedNode!"
|
||||
:widget-values="pendingWidgetValues"
|
||||
position="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouse, useRafFn } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
|
||||
|
||||
const { x, y, sourceType } = useMouse({ type: 'client' })
|
||||
|
||||
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
|
||||
const rafPosition = shallowRef<{ x: number; y: number }>()
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (sourceType.value === null) return
|
||||
const pos = rafPosition.value
|
||||
if (pos && pos.x === x.value && pos.y === y.value) return
|
||||
rafPosition.value = { x: x.value, y: y.value }
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
showGhost,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
rafPosition.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -66,7 +66,6 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -195,20 +194,15 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
}
|
||||
if (
|
||||
item.state === 'running' ||
|
||||
item.state === 'initialization' ||
|
||||
item.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(jobId)
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -292,17 +286,8 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
if (!jobIds.length) return
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
|
||||
await api.cancelJobs(jobIds)
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
@@ -115,69 +115,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
<MediaAssetSelectionBar
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
{{
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
:count="totalOutputCount"
|
||||
:show-delete="shouldShowDeleteButton"
|
||||
@deselect="handleDeselectAll"
|
||||
@download="handleDownloadSelected"
|
||||
@delete="handleDeleteSelected"
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
@@ -208,8 +153,6 @@
|
||||
import {
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
@@ -236,6 +179,7 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
@@ -257,7 +201,6 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
@@ -335,33 +278,6 @@ const {
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold matches when grid switches from 2 columns to 1 column
|
||||
// 2 columns need about ~430px
|
||||
const COMPACT_MODE_THRESHOLD_PX = 430
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
@@ -429,6 +345,10 @@ const previewableVisibleAssets = computed(() =>
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const totalOutputCount = computed(() =>
|
||||
getTotalOutputCount(selectedAssets.value)
|
||||
)
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
captureRoot,
|
||||
getRoot,
|
||||
resetRoot,
|
||||
mockAddNodeOnGraph,
|
||||
mockStartDrag,
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
resetRoot: () => {
|
||||
capturedRoot = null
|
||||
},
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -37,8 +37,8 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles model click and adds node to graph', async () => {
|
||||
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
|
||||
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
const mockWidget = { name: 'ckpt_name', value: '' }
|
||||
const mockGraphNode = { widgets: [mockWidget] }
|
||||
|
||||
mockGetNodeProvider.mockReturnValue({
|
||||
nodeDef: mockNodeDef,
|
||||
key: 'ckpt_name'
|
||||
})
|
||||
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
@@ -198,8 +195,10 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
await modelLeaf?.handleClick?.(mockEvent)
|
||||
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
|
||||
expect(mockWidget.value).toBe('model.safetensors')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles folder expansion on click', async () => {
|
||||
|
||||
@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
)
|
||||
if (widget) {
|
||||
widget.value = model.file_name
|
||||
}
|
||||
startModelLoaderDrag(provider, model.file_name)
|
||||
}
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
|
||||
@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
isDragging: { value: false },
|
||||
draggedNode: { value: null },
|
||||
cursorPosition: { value: { x: 0, y: 0 } },
|
||||
startDrag: vi.fn(),
|
||||
cancelDrag: vi.fn(),
|
||||
setupGlobalListeners: vi.fn(),
|
||||
cleanupGlobalListeners: vi.fn()
|
||||
cancelDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="hasNoMatches"
|
||||
@@ -215,7 +214,6 @@ import type {
|
||||
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
|
||||
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
|
||||
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-10000"
|
||||
:style="{
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
233
src/components/topbar/WorkflowTab.test.ts
Normal file
233
src/components/topbar/WorkflowTab.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { markRaw } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import type * as ExecutionStoreModule from '@/stores/executionStore'
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
|
||||
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
|
||||
new Map()
|
||||
),
|
||||
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ExecutionStoreModule>()
|
||||
return {
|
||||
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowStatus(workflow: object | undefined | null) {
|
||||
if (!workflow) return undefined
|
||||
return mockWorkflowStatus.value.get(workflow)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
|
||||
usePragmaticDraggable: vi.fn(),
|
||||
usePragmaticDroppable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
|
||||
useWorkflowActionsMenu: () => ({
|
||||
menuItems: { value: [] }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
closeWorkflow: mockCloseWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
getThumbnail: vi.fn(() => null)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTabPopover.vue', () => ({
|
||||
default: {
|
||||
render: () => null,
|
||||
methods: {
|
||||
showPopover: () => {},
|
||||
hidePopover: () => {},
|
||||
togglePopover: () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import WorkflowTab from './WorkflowTab.vue'
|
||||
|
||||
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
|
||||
|
||||
const statusAriaLabels: Record<WorkflowExecutionStatus, string> = {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { close: 'Close', ...statusAriaLabels }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type WorkflowOption = WorkflowTabProps['workflowOption']
|
||||
type Workflow = WorkflowOption['workflow']
|
||||
type WorkflowOverrides = Partial<Workflow>
|
||||
|
||||
// ComfyWorkflow has many required fields the component never reads (file
|
||||
// IO, change tracking). Validate the fields we *do* set against the real
|
||||
// type via Partial<Workflow>, then cast — adding/renaming a read field in
|
||||
// the component will fail typecheck on the override map.
|
||||
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
|
||||
const workflow = {
|
||||
key: 'test-key',
|
||||
path: '/workflows/test.json',
|
||||
filename: 'test.json',
|
||||
isPersisted: true,
|
||||
isModified: false,
|
||||
activeMode: 'graph',
|
||||
changeTracker: null,
|
||||
...overrides
|
||||
} satisfies WorkflowOverrides
|
||||
// markRaw keeps a stable identity through prop reactivity so the store's
|
||||
// identity-based status lookup resolves against the same object.
|
||||
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
|
||||
}
|
||||
|
||||
function renderTab({
|
||||
workflowOption = makeWorkflowOption(),
|
||||
activeWorkflowKey = 'other-key'
|
||||
}: {
|
||||
workflowOption?: WorkflowOption
|
||||
activeWorkflowKey?: string
|
||||
} = {}) {
|
||||
return render(WorkflowTab, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
workspace: { shiftDown: false },
|
||||
workflow: {
|
||||
activeWorkflow: { key: activeWorkflowKey }
|
||||
},
|
||||
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
|
||||
}
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
WorkflowActionsList: true,
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
workflowOption,
|
||||
isFirst: false,
|
||||
isLast: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowTab - workflow status indicator', () => {
|
||||
beforeEach(() => {
|
||||
mockWorkflowStatus.value = new Map()
|
||||
})
|
||||
|
||||
it.for(['running', 'completed', 'failed'] as const)(
|
||||
'labels the %s indicator with a translated status name',
|
||||
(status) => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
|
||||
|
||||
renderTab({ workflowOption })
|
||||
expect(
|
||||
screen.getByRole('img', { name: statusAriaLabels[status] })
|
||||
).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not badge the active tab with its own status', () => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
|
||||
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
|
||||
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
|
||||
})
|
||||
|
||||
it('shows the unsaved dot when modified and autosave is off', () => {
|
||||
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
|
||||
|
||||
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
|
||||
})
|
||||
|
||||
it('workflow status replaces the unsaved dot', () => {
|
||||
const workflowOption = makeWorkflowOption({ isPersisted: false })
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption })
|
||||
expect(
|
||||
screen.getByRole('img', { name: statusAriaLabels.running })
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowTab - close button', () => {
|
||||
beforeEach(() => {
|
||||
mockCloseWorkflow.mockClear()
|
||||
})
|
||||
|
||||
it('delegates close to workflow service with the tab workflow', async () => {
|
||||
renderTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('close-workflow-button'))
|
||||
|
||||
expect(mockCloseWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'test-key' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,19 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<i
|
||||
v-if="workflowStatus"
|
||||
role="img"
|
||||
:aria-label="workflowStatusLabel"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
|
||||
workflowStatusIconClasses[workflowStatus]
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
v-else-if="shouldShowUnsavedIndicator"
|
||||
data-testid="workflow-dirty-indicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
@@ -32,6 +43,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
data-testid="close-workflow-button"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
@@ -85,8 +97,14 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
import {
|
||||
useExecutionStore,
|
||||
WORKFLOW_STATUS_I18N_KEYS
|
||||
} from '@/stores/executionStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
@@ -113,6 +131,7 @@ const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
@@ -125,7 +144,7 @@ const autoSaveDelay = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSaveDelay')
|
||||
)
|
||||
|
||||
const shouldShowStatusIndicator = computed(() => {
|
||||
const shouldShowUnsavedIndicator = computed(() => {
|
||||
if (workspaceStore.shiftDown) {
|
||||
// Branch 1: Shift key is held down, do not show the status indicator.
|
||||
return false
|
||||
@@ -160,6 +179,27 @@ const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
|
||||
running:
|
||||
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
|
||||
completed: 'icon-[lucide--circle-check] text-success-background',
|
||||
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
|
||||
}
|
||||
|
||||
// The active tab doesn't badge its own status - the user is already looking
|
||||
// at it. Background tabs surface the recorded execution status.
|
||||
const workflowStatus = computed(() =>
|
||||
isActiveTab.value
|
||||
? undefined
|
||||
: executionStore.getWorkflowStatus(props.workflowOption.workflow)
|
||||
)
|
||||
|
||||
const workflowStatusLabel = computed(() =>
|
||||
workflowStatus.value
|
||||
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
|
||||
: undefined
|
||||
)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,10 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
|
||||
useWorkflowStatusDismissal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
|
||||
@@ -117,6 +117,7 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
@@ -145,6 +146,9 @@ const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
useWorkflowStatusDismissal()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
|
||||
110
src/components/ui/credit-slider/CreditSlider.stories.ts
Normal file
110
src/components/ui/credit-slider/CreditSlider.stories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const meta: Meta<typeof CreditSlider> = {
|
||||
title: 'Components/CreditSlider',
|
||||
component: CreditSlider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
// Previews at the real layout width: the Figma "Team Plan" card column is
|
||||
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
|
||||
// width the slider actually renders into inside PricingTableWorkspace.
|
||||
template: '<div class="w-[512px] px-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
|
||||
// In production this comes from the API; here it shows the stops being driven
|
||||
// entirely through props rather than the hardcoded default constant.
|
||||
const apiTeamCreditStops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_200',
|
||||
credits: 42_200,
|
||||
yearly: { price_cents: 20_000, discount_percent: 0 }
|
||||
},
|
||||
{
|
||||
id: 'team_400',
|
||||
credits: 84_400,
|
||||
yearly: { price_cents: 38_000, discount_percent: 5 }
|
||||
},
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { price_cents: 63_000, discount_percent: 10 }
|
||||
},
|
||||
{
|
||||
id: 'team_1400',
|
||||
credits: 295_400,
|
||||
yearly: { price_cents: 119_000, discount_percent: 15 }
|
||||
},
|
||||
{
|
||||
id: 'team_2500',
|
||||
credits: 527_500,
|
||||
yearly: { price_cents: 200_000, discount_percent: 20 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
|
||||
// The pre-discount list price is recovered as discounted / (1 - discount).
|
||||
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
|
||||
credits: s.credits,
|
||||
discountPercentYearly: s.yearly.discount_percent,
|
||||
usd: Math.round(
|
||||
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
|
||||
)
|
||||
}))
|
||||
|
||||
export const BackendDrivenStops: Story = {
|
||||
name: 'Backend-driven stops (props)',
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const defaultStopIndex = apiTeamCreditStops.default_stop_index
|
||||
const value = ref(mappedStops[defaultStopIndex].usd)
|
||||
return { args, value, mappedStops, defaultStopIndex }
|
||||
},
|
||||
template:
|
||||
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
208
src/components/ui/credit-slider/CreditSlider.test.ts
Normal file
208
src/components/ui/credit-slider/CreditSlider.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
usdPerMonth: 'USD / mo',
|
||||
billedYearly: '{total} Billed yearly',
|
||||
billedMonthly: 'Billed monthly',
|
||||
creditSliderSave: 'Save {percent}% ({amount})'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderSlider(props: Record<string, unknown> = {}) {
|
||||
return render(CreditSlider, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('CreditSlider', () => {
|
||||
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
|
||||
renderSlider()
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '4')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
|
||||
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(1400)
|
||||
})
|
||||
|
||||
it('snaps to the previous fixed stop on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(400)
|
||||
})
|
||||
|
||||
it('emits change with the full {index, usd, credits} payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({ modelValue: 700, onChange })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
index: 3,
|
||||
usd: 1400,
|
||||
credits: 295_400
|
||||
})
|
||||
})
|
||||
|
||||
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({
|
||||
modelValue: 700,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate,
|
||||
onChange
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
|
||||
renderSlider() // default $700 stop → 10% yearly discount
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 10% ($70)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'$7,560'
|
||||
)
|
||||
})
|
||||
|
||||
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
|
||||
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 5% ($35)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'Billed monthly'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
|
||||
renderSlider({ modelValue: 400, cycle: 'monthly' })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 2.5% ($10)'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the discount UI at the 0% stop ($200)', async () => {
|
||||
renderSlider({ modelValue: 200 })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
|
||||
expect(
|
||||
screen.queryByTestId('credit-slider-original-price')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all five fixed credit stop labels', async () => {
|
||||
renderSlider({ modelValue: 700 })
|
||||
await flush()
|
||||
|
||||
const stops = within(screen.getByTestId('credit-slider-stops'))
|
||||
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
|
||||
expect(stops.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
|
||||
const stops = [
|
||||
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
|
||||
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
|
||||
]
|
||||
// No modelValue → the model default ($700) matches no stop, so selectedIndex
|
||||
// falls back to defaultStopIndex (here index 1 → $100).
|
||||
renderSlider({ stops, defaultStopIndex: 1 })
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
|
||||
|
||||
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$100')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 25% ($25)'
|
||||
)
|
||||
|
||||
// Only the prop's labels render — none of the DES-197 defaults.
|
||||
const labels = within(screen.getByTestId('credit-slider-stops'))
|
||||
expect(labels.getByText('10.6K')).toBeInTheDocument()
|
||||
expect(labels.getByText('21.1K')).toBeInTheDocument()
|
||||
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
|
||||
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
|
||||
expect(stop.credits).toBe(usdToCredits(stop.usd))
|
||||
}
|
||||
})
|
||||
})
|
||||
235
src/components/ui/credit-slider/CreditSlider.vue
Normal file
235
src/components/ui/credit-slider/CreditSlider.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TransitionPresets,
|
||||
usePreferredReducedMotion,
|
||||
useTransition
|
||||
} from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: rootClass,
|
||||
stops = TEAM_PLAN_CREDIT_STOPS,
|
||||
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
cycle = 'yearly'
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
|
||||
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
|
||||
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
|
||||
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
|
||||
*/
|
||||
stops?: readonly CreditStop[]
|
||||
/**
|
||||
* Stop selected when the bound value matches none (e.g. first render).
|
||||
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
|
||||
*/
|
||||
defaultStopIndex?: number
|
||||
/**
|
||||
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
|
||||
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
|
||||
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
|
||||
*/
|
||||
cycle?: 'monthly' | 'yearly'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Fired when the selected stop changes, with the full derived payload. */
|
||||
change: [stop: { index: number; usd: number; credits: number }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* v-model carries the selected USD value (one of the `stops`). The literal
|
||||
* default keeps `defineModel` statically analyzable; when custom `stops` are
|
||||
* passed without a matching v-model, `selectedIndex` falls back to
|
||||
* `defaultStopIndex`, so the displayed stop is still correct.
|
||||
*/
|
||||
const usd = defineModel<number>({
|
||||
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
const i = stops.findIndex((stop) => stop.usd === usd.value)
|
||||
if (i !== -1) return i
|
||||
// Fall back to the default stop, clamped into range: a backend-driven `stops`
|
||||
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
|
||||
// clamping keeps `current` defined and the price computeds below from reading
|
||||
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
|
||||
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
|
||||
})
|
||||
|
||||
const current = computed<CreditStop>(() => stops[selectedIndex.value])
|
||||
|
||||
// The discount applies to the monthly figure. Yearly uses the full
|
||||
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
|
||||
// shows the discounted monthly price, the struck pre-discount price, the
|
||||
// saving, and — for yearly — the annual total.
|
||||
const effectiveDiscountPercent = computed(() =>
|
||||
cycle === 'monthly'
|
||||
? current.value.discountPercentYearly / 2
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
/**
|
||||
* Smoothly count the price figures up/down as the slider moves between stops
|
||||
* instead of snapping. Honors the user's reduced-motion preference. The save
|
||||
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
|
||||
* tier, so animating the bracketed amount alone would read inconsistently.
|
||||
*/
|
||||
const prefersReducedMotion = usePreferredReducedMotion()
|
||||
const priceTween = {
|
||||
duration: 350,
|
||||
easing: TransitionPresets.easeOutCubic,
|
||||
disabled: computed(() => prefersReducedMotion.value === 'reduce')
|
||||
}
|
||||
const animatedMonthly = useTransition(discountedMonthly, priceTween)
|
||||
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
|
||||
|
||||
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
|
||||
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
|
||||
// Derive the yearly total from the displayed monthly so it always reads as
|
||||
// exactly 12× the price shown — even mid-count — rather than drifting as a
|
||||
// second, independently-phased tween would.
|
||||
const displayBilledYearly = computed(() => displayMonthly.value * 12)
|
||||
|
||||
/**
|
||||
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
|
||||
* model. Driving the slider in index space with `step = 1` guarantees the
|
||||
* thumb can only land on the fixed stops — never a value in between.
|
||||
*/
|
||||
const sliderModel = computed<number[]>({
|
||||
get: () => [selectedIndex.value],
|
||||
set: ([index]) => {
|
||||
const stop = stops[index]
|
||||
if (!stop) return
|
||||
usd.value = stop.usd
|
||||
emit('change', { index, usd: stop.usd, credits: stop.credits })
|
||||
}
|
||||
})
|
||||
|
||||
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
|
||||
|
||||
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
|
||||
const formatCreditsCompact = (value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value)
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
|
||||
<!-- Price: discounted monthly + struck pre-discount + save badge -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
class="text-base text-muted-foreground tabular-nums line-through"
|
||||
data-testid="credit-slider-original-price"
|
||||
>
|
||||
{{ formatUsd(displayOriginal) }}
|
||||
</span>
|
||||
<span class="text-base text-muted-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
|
||||
the right of the price; when the column narrows (mobile) it wraps
|
||||
and aligns left under the price instead (DES QA). -->
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
data-testid="credit-slider-save"
|
||||
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
|
||||
>
|
||||
{{
|
||||
t('subscription.creditSliderSave', {
|
||||
percent: effectiveDiscountPercent,
|
||||
amount: formatUsd(saveAmount)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 text-sm text-muted-foreground tabular-nums"
|
||||
data-testid="credit-slider-billed-yearly"
|
||||
>
|
||||
{{
|
||||
cycle === 'monthly'
|
||||
? t('subscription.billedMonthly')
|
||||
: t('subscription.billedYearly', {
|
||||
total: formatUsd(displayBilledYearly)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
|
||||
<Slider
|
||||
v-model="sliderModel"
|
||||
:min="0"
|
||||
:max="lastIndex"
|
||||
:step="1"
|
||||
:disabled="disabled"
|
||||
range-class="bg-base-foreground"
|
||||
thumb-class="bg-base-foreground"
|
||||
/>
|
||||
|
||||
<!-- Credit stop labels; the selected stop is emphasized -->
|
||||
<ol
|
||||
data-testid="credit-slider-stops"
|
||||
class="m-0 flex list-none justify-between p-0"
|
||||
>
|
||||
<li
|
||||
v-for="(stop, i) in stops"
|
||||
:key="stop.usd"
|
||||
:data-selected="i === selectedIndex ? '' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 text-xs tabular-nums',
|
||||
i === selectedIndex
|
||||
? 'font-semibold text-base-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatCreditsCompact(stop.credits) }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,7 +15,11 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
SliderRootProps & { class?: HTMLAttributes['class'] }
|
||||
SliderRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
rangeClass?: HTMLAttributes['class']
|
||||
thumbClass?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const pressed = ref(false)
|
||||
@@ -25,7 +29,7 @@ const setPressed = (val: boolean) => {
|
||||
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -60,7 +64,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
:class="
|
||||
cn(
|
||||
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
||||
props.rangeClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
@@ -74,7 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
'cursor-grab',
|
||||
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
{ 'cursor-grabbing': pressed },
|
||||
props.thumbClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -225,6 +225,40 @@ describe('useAuthActions.reportError', () => {
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
// The backend wraps the rejection in a generic code; we match the token in
|
||||
// the message, so it must win over the auth.errors.${code} fallback.
|
||||
reportError(
|
||||
new FirebaseError(
|
||||
'auth/internal-error',
|
||||
'Account creation is temporarily unavailable. (ref: signup_blocked)'
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.signupBlocked'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches the signup_blocked token case-insensitively', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(
|
||||
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
|
||||
)
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.signupBlocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
|
||||
@@ -47,6 +47,19 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (
|
||||
error instanceof FirebaseError &&
|
||||
error.message.toLowerCase().includes('signup_blocked')
|
||||
) {
|
||||
// Match on `error.message`, not `error.code`: Firebase `beforeUserCreated`
|
||||
// rejections collapse the thrown code into a generic `auth/internal-error`,
|
||||
// so the message is the only reliable channel. `signup_blocked` is a
|
||||
// cross-repo contract token; matched case-insensitively.
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('auth.errors.signupBlocked')
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
SubscriptionTier,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
@@ -71,6 +73,10 @@ export interface BillingState {
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
/** Team per-credit pricing ladder; null for personal/legacy. */
|
||||
teamCreditStops: ComputedRef<TeamCreditStops | null>
|
||||
/** The team's currently-subscribed credit stop; null for personal/legacy. */
|
||||
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
@@ -83,5 +89,10 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits
|
||||
mockPurchaseCredits,
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn()
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
} as BillingStatusResponse
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
@@ -103,12 +122,7 @@ vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingStatus: vi.fn().mockResolvedValue({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}),
|
||||
getBillingStatus: vi.fn(() => Promise.resolve(mockBillingStatus.value)),
|
||||
getBillingBalance: vi.fn().mockResolvedValue({
|
||||
amount_micros: 10000000,
|
||||
currency: 'usd'
|
||||
@@ -125,6 +139,7 @@ describe('useBillingContext', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
@@ -252,4 +267,158 @@ describe('useBillingContext', () => {
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isLegacyTeamPlan } = useBillingContext()
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for an active team plan: team- slug and no credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for any legacy team tier, not just standard', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-pro-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a new credit-slider team subscriber', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
// Real BE shape: underscore slug + populated credit stop. (subscription_tier
|
||||
// is 'TEAM' on the wire, not yet in the FE SubscriptionTier union, so it is
|
||||
// omitted here — the predicate does not depend on it.)
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 147700,
|
||||
stop_usd: 700
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a new team sub even before its credit stop is populated', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
// Provisioning lag: credit stop not yet attached. The underscore slug
|
||||
// (team_per_credit, not team-) must still exclude it from the legacy table.
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team_per_credit_annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stays true for a cancelled-but-still-active legacy team sub', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-standard-annual',
|
||||
cancel_at: '2099-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a FREE-tier team even on a team- prefixed slug', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'FREE',
|
||||
plan_slug: 'team-free'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('matches the legacy slug case-insensitively', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'Team-Standard-Annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,12 @@ import type {
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
// Legacy per-member team plans use a hyphenated `team-{tier}-{cycle}` slug; the
|
||||
// new credit-slider plan uses an underscore `team_per_credit_{cycle}` slug and
|
||||
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
|
||||
// new sub is never misrouted even before its credit stop is populated.
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
@@ -116,12 +122,32 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const teamCreditStops = computed(() =>
|
||||
toValue(activeContext.value.teamCreditStops)
|
||||
)
|
||||
|
||||
const currentTeamCreditStop = computed(() =>
|
||||
toValue(activeContext.value.currentTeamCreditStop)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
isActiveSubscription.value &&
|
||||
!isFreeTier.value &&
|
||||
currentTeamCreditStop.value === null &&
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false)
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -254,10 +280,13 @@ function useBillingContextInternal(): BillingContext {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -93,6 +93,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
const teamCreditStops = computed(() => null)
|
||||
const currentTeamCreditStop = computed(() => null)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
@@ -200,6 +202,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,7 +15,14 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,7 +28,26 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
interface UseDomClippingReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updateClipPath: (
|
||||
element: HTMLElement,
|
||||
canvasElement: HTMLCanvasElement,
|
||||
isSelected: boolean,
|
||||
selectedArea?: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useDomClipping(
|
||||
options: ClippingOptions = {}
|
||||
): UseDomClippingReturn {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
184
src/composables/graph/useGroupContextMenu.test.ts
Normal file
184
src/composables/graph/useGroupContextMenu.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const {
|
||||
mockShowNodeOptions,
|
||||
mockUpdateSelectedItems,
|
||||
mockGetCanvasContextMenuTarget
|
||||
} = vi.hoisted(() => ({
|
||||
mockShowNodeOptions: vi.fn(),
|
||||
mockUpdateSelectedItems: vi.fn(),
|
||||
mockGetCanvasContextMenuTarget: vi.fn<
|
||||
() => { reroute?: unknown; group?: unknown }
|
||||
>(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({
|
||||
showNodeOptions: mockShowNodeOptions
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ updateSelectedItems: mockUpdateSelectedItems })
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/canvas/getCanvasContextMenuTarget', () => ({
|
||||
getCanvasContextMenuTarget: mockGetCanvasContextMenuTarget
|
||||
}))
|
||||
|
||||
interface StubCanvas {
|
||||
graph: object
|
||||
deselectAll: ReturnType<typeof vi.fn>
|
||||
selectedItems: Set<unknown>
|
||||
state: { selectionChanged: boolean }
|
||||
}
|
||||
|
||||
describe('useGroupContextMenu', () => {
|
||||
const event = fromPartial<CanvasPointerEvent>({ canvasX: 10, canvasY: 20 })
|
||||
let group: {
|
||||
id: number
|
||||
selected?: boolean
|
||||
recomputeInsideNodes: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let legacyMenuMock: ReturnType<typeof vi.fn>
|
||||
let stubCanvas: StubCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
LiteGraph.vueNodesMode = true
|
||||
group = { id: 1, recomputeInsideNodes: vi.fn() }
|
||||
mockGetCanvasContextMenuTarget.mockReturnValue({ group })
|
||||
|
||||
legacyMenuMock = vi.fn()
|
||||
LGraphCanvas.prototype.processContextMenu = fromAny(legacyMenuMock)
|
||||
|
||||
useGroupContextMenu()
|
||||
|
||||
stubCanvas = {
|
||||
graph: {},
|
||||
deselectAll: vi.fn(),
|
||||
selectedItems: new Set(),
|
||||
state: { selectionChanged: false }
|
||||
}
|
||||
stubCanvas.deselectAll.mockImplementation(() => {
|
||||
stubCanvas.selectedItems.clear()
|
||||
})
|
||||
})
|
||||
|
||||
function invoke(node: LGraphNode | undefined) {
|
||||
LGraphCanvas.prototype.processContextMenu.call(
|
||||
fromAny(stubCanvas),
|
||||
node,
|
||||
event
|
||||
)
|
||||
}
|
||||
|
||||
it('opens the Vue menu and selects only the group in Nodes 2.0 mode', () => {
|
||||
invoke(undefined)
|
||||
|
||||
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(group.selected).toBe(true)
|
||||
expect(stubCanvas.selectedItems.has(group)).toBe(true)
|
||||
expect(stubCanvas.state.selectionChanged).toBe(true)
|
||||
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
|
||||
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
|
||||
expect(mockUpdateSelectedItems.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockShowNodeOptions.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(legacyMenuMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when a node is under the cursor', () => {
|
||||
invoke(fromPartial<LGraphNode>({}))
|
||||
|
||||
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu in legacy (non-Nodes 2.0) mode', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when no group is under the cursor', () => {
|
||||
mockGetCanvasContextMenuTarget.mockReturnValue({})
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
expect(stubCanvas.selectedItems.size).toBe(0)
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when the cursor is on a reroute', () => {
|
||||
mockGetCanvasContextMenuTarget.mockReturnValue({
|
||||
reroute: { id: 5 },
|
||||
group
|
||||
})
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
expect(stubCanvas.selectedItems.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the menu open without re-selecting when only the group is selected', () => {
|
||||
group.selected = true
|
||||
stubCanvas.selectedItems.add(group)
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(stubCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
expect(stubCanvas.selectedItems.size).toBe(1)
|
||||
expect(stubCanvas.selectedItems.has(group)).toBe(true)
|
||||
expect(stubCanvas.state.selectionChanged).toBe(false)
|
||||
expect(group.recomputeInsideNodes).not.toHaveBeenCalled()
|
||||
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
|
||||
expect(legacyMenuMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reselects the group when selected child nodes would hide group actions', () => {
|
||||
const childNode = { selected: true }
|
||||
group.selected = true
|
||||
stubCanvas.selectedItems.add(group)
|
||||
stubCanvas.selectedItems.add(childNode)
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(stubCanvas.selectedItems.size).toBe(1)
|
||||
expect(stubCanvas.selectedItems.has(group)).toBe(true)
|
||||
expect(stubCanvas.state.selectionChanged).toBe(true)
|
||||
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
|
||||
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
|
||||
expect(legacyMenuMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when the canvas has no graph', () => {
|
||||
LGraphCanvas.prototype.processContextMenu.call(
|
||||
fromAny({ deselectAll: vi.fn() }),
|
||||
undefined,
|
||||
event
|
||||
)
|
||||
|
||||
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
49
src/composables/graph/useGroupContextMenu.ts
Normal file
49
src/composables/graph/useGroupContextMenu.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { getCanvasContextMenuTarget } from '@/lib/litegraph/src/canvas/getCanvasContextMenuTarget'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Routes Nodes 2.0 group right-clicks to Vue while nodes, reroutes,
|
||||
* background, and legacy mode stay on litegraph.
|
||||
*/
|
||||
export function useGroupContextMenu() {
|
||||
const original = LGraphCanvas.prototype.processContextMenu
|
||||
|
||||
function processContextMenuWithVueGroupMenu(
|
||||
this: LGraphCanvas,
|
||||
...args: Parameters<typeof original>
|
||||
): void {
|
||||
const [node, event] = args
|
||||
|
||||
if (node || !LiteGraph.vueNodesMode || !this.graph) {
|
||||
original.apply(this, args)
|
||||
return
|
||||
}
|
||||
|
||||
const { reroute, group } = getCanvasContextMenuTarget(
|
||||
this,
|
||||
event.canvasX,
|
||||
event.canvasY
|
||||
)
|
||||
if (reroute || !group) {
|
||||
original.apply(this, args)
|
||||
return
|
||||
}
|
||||
|
||||
const groupIsOnlySelection =
|
||||
this.selectedItems.size === 1 && this.selectedItems.has(group)
|
||||
|
||||
if (!groupIsOnlySelection) {
|
||||
this.deselectAll()
|
||||
group.selected = true
|
||||
group.recomputeInsideNodes()
|
||||
this.selectedItems.add(group)
|
||||
this.state.selectionChanged = true
|
||||
}
|
||||
useCanvasStore().updateSelectedItems()
|
||||
showNodeOptions(event)
|
||||
}
|
||||
|
||||
LGraphCanvas.prototype.processContextMenu = processContextMenuWithVueGroupMenu
|
||||
}
|
||||
@@ -181,4 +181,24 @@ describe('useMaskEditorSaver', () => {
|
||||
expect(store.nodeOutputs[locatorId]).toBeDefined()
|
||||
expect(store.nodeOutputs[locatorId]?.images?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('omits subfolder from the upload FormData under the unified contract', async () => {
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
await save()
|
||||
|
||||
// The unified contract uploads to /upload/image with only image + type;
|
||||
// subfolder is intentionally omitted (the server assigns it). Assert it
|
||||
// here so the next reader knows the omission is deliberate, not accidental.
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
const [, init] = fetchApiMock.mock.calls[0]
|
||||
const body = init?.body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -6,7 +8,6 @@ import type {
|
||||
EditorOutputLayer,
|
||||
ImageRef
|
||||
} from '@/stores/maskEditorDataStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
@@ -209,18 +210,11 @@ export function useMaskEditorSaver() {
|
||||
}
|
||||
|
||||
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
|
||||
const sourceRef = dataStore.inputData!.sourceRef
|
||||
|
||||
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
|
||||
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
|
||||
const actualPaintedRef = await uploadImage(
|
||||
outputData.paintedImage,
|
||||
sourceRef
|
||||
)
|
||||
|
||||
const actualPaintedMaskedRef = await uploadMask(
|
||||
outputData.paintedMaskedImage,
|
||||
actualPaintedRef
|
||||
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
|
||||
const actualPaintRef = await uploadLayer(outputData.paintLayer)
|
||||
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
|
||||
const actualPaintedMaskedRef = await uploadLayer(
|
||||
outputData.paintedMaskedImage
|
||||
)
|
||||
|
||||
outputData.maskedImage.ref = actualMaskedRef
|
||||
@@ -229,50 +223,10 @@ export function useMaskEditorSaver() {
|
||||
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
|
||||
}
|
||||
|
||||
async function uploadMask(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/mask', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -280,23 +234,35 @@ export function useMaskEditorSaver() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Failed to upload ${layer.ref.filename} (${response.status}${body ? `: ${body}` : ''})`
|
||||
)
|
||||
}
|
||||
|
||||
let data: UploadImageResponse
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
data = await response.json()
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
throw new Error(
|
||||
`Invalid upload response for ${layer.ref.filename}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
if (!data?.name) {
|
||||
throw new Error(
|
||||
`Upload response missing 'name' for ${layer.ref.filename}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || '',
|
||||
type: data.type || 'input'
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNodePreview(
|
||||
@@ -322,19 +288,8 @@ export function useMaskEditorSaver() {
|
||||
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
// Widget value format differs between Cloud and OSS:
|
||||
// - Cloud: JUST the filename (subfolder handled by backend)
|
||||
// - OSS: subfolder/filename (traditional format)
|
||||
let widgetValue: string
|
||||
if (isCloud) {
|
||||
widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
} else {
|
||||
widgetValue =
|
||||
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
|
||||
mainRef.filename +
|
||||
(mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
}
|
||||
const widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
|
||||
imageWidget.value = widgetValue
|
||||
|
||||
|
||||
87
src/composables/node/startModelNodeDragFromAsset.test.ts
Normal file
87
src/composables/node/startModelNodeDragFromAsset.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
|
||||
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('startModelNodeDragFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('starts a ghost drag for the resolved node carrying the widget value', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error).toBeUndefined()
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('threads the node-add source through to the drag', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
startModelNodeDragFromAsset(createAsset(), 'asset_browser')
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
|
||||
source: 'asset_browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries no widget value when the provider has no key', () => {
|
||||
const nodeDef = { name: 'FL_ChatterboxVC' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
|
||||
|
||||
startModelNodeDragFromAsset(
|
||||
createAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: undefined,
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the resolution error and does not start a drag for an invalid asset', () => {
|
||||
mockGetNodeProvider.mockReturnValue(null)
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error?.code).toBe('NO_PROVIDER')
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
38
src/composables/node/startModelNodeDragFromAsset.ts
Normal file
38
src/composables/node/startModelNodeDragFromAsset.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { NodeAddSource } from '@/platform/telemetry/types'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Arms a ghost drag for a model loader node. Providers with no widget key
|
||||
* (auto-load nodes) start the drag without widget values.
|
||||
*/
|
||||
export function startModelLoaderDrag(
|
||||
provider: ModelNodeProvider,
|
||||
filename: string,
|
||||
source: NodeAddSource = 'sidebar_drag'
|
||||
) {
|
||||
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
|
||||
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues, source })
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a ghost drag for the model loader node described by an asset. The
|
||||
* node is created where the user next clicks the canvas, with the asset's
|
||||
* filename written into the loader widget.
|
||||
*
|
||||
* @returns the resolution error when the asset cannot be mapped to a node,
|
||||
* otherwise `undefined`.
|
||||
*/
|
||||
export function startModelNodeDragFromAsset(
|
||||
asset: AssetItem,
|
||||
source: NodeAddSource = 'sidebar_drag'
|
||||
): ResolveModelNodeError | undefined {
|
||||
const resolved = resolveModelNodeFromAsset(asset)
|
||||
if (!resolved.success) return resolved.error
|
||||
|
||||
const { provider, filename } = resolved.value
|
||||
startModelLoaderDrag(provider, filename, source)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas
|
||||
mockCanvas,
|
||||
mockToastAdd
|
||||
} = vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
@@ -15,6 +16,7 @@ const {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockToastAdd: vi.fn(),
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
@@ -37,6 +39,12 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
|
||||
@@ -54,8 +62,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
cancelDrag()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -71,22 +79,6 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(draggedNode.value).toBe(mockNodeDef)
|
||||
})
|
||||
|
||||
it('should set dragMode to click by default', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
|
||||
it('should set dragMode to native when specified', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
@@ -102,30 +94,15 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should reset dragMode to click', () => {
|
||||
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
expect(dragMode.value).toBe('native')
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGlobalListeners', () => {
|
||||
it('should add event listeners to document', () => {
|
||||
describe('drag listener lifecycle', () => {
|
||||
it('should attach document listeners on startDrag', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
@@ -142,35 +119,53 @@ describe('useNodeDragToCanvas', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should only setup listeners once', () => {
|
||||
it('should not attach drag listeners until a drag starts', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should detach document listeners on cancelDrag', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should only attach listeners once across re-arms', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
const callCount = addEventListenerSpy.mock.calls.length
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursorPosition', () => {
|
||||
it('should update on pointermove', () => {
|
||||
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
const pointerEvent = new PointerEvent('pointermove', {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
@@ -181,9 +176,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -206,10 +199,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -224,10 +214,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should cancel drag on Escape key', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
@@ -239,10 +226,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not cancel drag on other keys', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
@@ -262,8 +246,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -277,6 +260,102 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should apply the requested widget values to the placed node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('should warn but still place the node when a requested widget is missing', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1, widgets: [] }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
detail: 'assetBrowser.failedToSetModelValue'
|
||||
})
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ckpt_name')
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an error toast when the graph fails to add the node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'assetBrowser.failedToCreateNode'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
@@ -286,9 +365,9 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -311,11 +390,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
@@ -341,7 +417,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
@@ -359,7 +435,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(600, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -377,7 +453,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'click')
|
||||
startDrag(mockNodeDef)
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -392,14 +468,12 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging, dragMode } =
|
||||
useNodeDragToCanvas()
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -426,31 +500,29 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when not dragging', () => {
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
it('should not stop propagation once the drag is cancelled', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation in native drag mode', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
@@ -477,10 +549,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
}
|
||||
|
||||
it('should prefer tracked drag position over dragend coordinates', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
fireDrag(250, 250)
|
||||
// dragend supplies a bad position (the Firefox bug); the tracked one
|
||||
@@ -494,10 +564,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should ignore drag events with (0, 0)', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
fireDrag(250, 250)
|
||||
fireDrag(0, 0)
|
||||
@@ -510,10 +578,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should fall back to dragend coordinates when no drag fired', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
@@ -523,32 +589,14 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore dragover events fired before startDrag', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
fireDrag(250, 250)
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 300,
|
||||
clientY: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear tracked position between drags', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
fireDrag(250, 250)
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
// Second drag - no drag events, so we should fall back to args.
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import type { NodeAddSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type DragMode = 'click' | 'native'
|
||||
type WidgetValues = Record<string, string>
|
||||
type Position = { x: number; y: number }
|
||||
|
||||
interface StartDragOptions {
|
||||
mode?: DragMode
|
||||
widgetValues?: WidgetValues
|
||||
source?: NodeAddSource
|
||||
}
|
||||
|
||||
const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
|
||||
const lastNativeDragPosition = shallowRef<Position>()
|
||||
const pendingWidgetValues = shallowRef<WidgetValues>()
|
||||
const pendingSource = ref<NodeAddSource>('sidebar_drag')
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
cursorPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// Firefox dragend can report stale clientX/Y and `drag` can fire with
|
||||
// (0, 0). dragover on the target reliably reports real client coords.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
|
||||
@@ -27,11 +36,20 @@ function trackNativeDragPosition(e: DragEvent) {
|
||||
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const widget = node.widgets?.find((w) => w.name === name)
|
||||
if (!widget) {
|
||||
console.error(`Widget ${name} not found on node ${node.type}`)
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('assetBrowser.failedToSetModelValue')
|
||||
})
|
||||
continue
|
||||
}
|
||||
widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
@@ -59,10 +77,22 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
const node = withNodeAddSource(pendingSource.value, () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (node) canvas.selectItems([node])
|
||||
if (!node) {
|
||||
console.error(`Failed to add node to graph: ${nodeDef.name}`)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (pendingWidgetValues.value)
|
||||
applyWidgetValues(node, pendingWidgetValues.value)
|
||||
canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -92,7 +122,6 @@ function setupGlobalListeners() {
|
||||
if (listenersSetup) return
|
||||
listenersSetup = true
|
||||
|
||||
document.addEventListener('pointermove', updatePosition)
|
||||
document.addEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
@@ -103,22 +132,37 @@ function cleanupGlobalListeners() {
|
||||
if (!listenersSetup) return
|
||||
listenersSetup = false
|
||||
|
||||
document.removeEventListener('pointermove', updatePosition)
|
||||
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('dragover', trackNativeDragPosition)
|
||||
}
|
||||
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
cancelDrag()
|
||||
}
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
pendingWidgetValues.value = undefined
|
||||
pendingSource.value = 'sidebar_drag'
|
||||
cleanupGlobalListeners()
|
||||
}
|
||||
|
||||
export function useNodeDragToCanvas() {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
|
||||
function startDrag(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
{
|
||||
mode = 'click',
|
||||
widgetValues,
|
||||
source = 'sidebar_drag'
|
||||
}: StartDragOptions = {}
|
||||
) {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
pendingWidgetValues.value = widgetValues
|
||||
pendingSource.value = source
|
||||
setupGlobalListeners()
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
@@ -134,12 +178,9 @@ export function useNodeDragToCanvas() {
|
||||
return {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
pendingWidgetValues,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleNativeDrop,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
handleNativeDrop
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user