Compare commits

..

6 Commits

Author SHA1 Message Date
Michael B
053e731445 refactor(website): extract LOCALES constant in drops e2e spec
Three loop-based drops tests duplicated the same [[PATH_EN, 'en'],
[PATH_ZH, 'zh-CN']] inline literal. Extract to a module-level LOCALES
constant typed as ReadonlyArray<readonly [string, Locale]>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 12:41:19 -04:00
Michael B
93495bf44f feat(website): add closing CTA to /drops and extend CtaCenter01
Adds the two-button closing CTA ("Everything Comfy ships. All in one
place.") to /drops and /zh-CN/drops, targeting Comfy Cloud and Comfy
Workflows. Extends CtaCenter01 with optional secondaryCta and termsLink
so both the affiliate page (primary + terms) and the drops page (primary
+ secondary) share the block, and migrates it off the deprecated
BrandButton to the shadcn-vue Button. Adds desktop and mobile e2e
coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 11:42:11 -04:00
Michael B
d34d59a7b2 feat(website): add live-stream subscribe banner to /drops
Page-scoped purple announcement bar at the top of /drops and
/zh-CN/drops with a sign-up link that opens YouTube in a new tab
(temporary V1 fallback until an event-registration URL is provided).
Adds drops.banner.* translations and an e2e test asserting the text and
link behavior in both locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 11:28:46 -04:00
Michael B
3e1039c624 refactor(website): tighten drops hero block and spec
Convert HeroCenter01's resolveRel arrow expression to a function
declaration (per project convention), and extract a heroSection helper
in the drops e2e spec to remove duplicated locator scaffolding across
the two CTA tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 11:15:47 -04:00
Michael B
eb83aa5253 feat(website): add centered hero to /drops landing page
Adds HeroCenter01, a generic centered hero block, with the Comfy
wordmark, "Everything new in ComfyUI" headline, and primary/secondary
CTAs (Download Desktop + Launch Cloud). Mounted on both /drops and
/zh-CN/drops via a page-scoped HeroSection template. Built on the
shadcn-vue Button (the BrandButton replacement) and types Cta target/rel
from Vue's AnchorHTMLAttributes instead of a hardcoded union.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 11:06:15 -04:00
Michael B
90618c2c5a feat(website): add /drops landing page skeleton (en + zh-CN)
Scaffolds the /drops route as the foundation for the upcoming "Latest
Drops" marketing page. Adds the English page, zh-CN counterpart, route
entry, head metadata, and a Playwright smoke spec covering both locales
and indexability. Section components (hero, banner, grid, CTA) land in
follow-up slices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 10:30:11 -04:00
23 changed files with 601 additions and 130 deletions

View File

@@ -15,11 +15,6 @@ 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

View File

@@ -92,7 +92,9 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -0,0 +1,169 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
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)
})
})
}
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.describe('Drops landing — mobile @mobile', () => {
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
)
})
})

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
import type { AnchorHTMLAttributes } from 'vue'
import Button from '../ui/button/Button.vue'
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
}
type TermsLink = {
@@ -12,11 +15,18 @@ type TermsLink = {
href: string
}
defineProps<{
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
heading: string
primaryCta: Cta
termsLink: TermsLink
secondaryCta?: Cta
termsLink?: TermsLink
}>()
function resolveRel(cta: Cta): AnchorHTMLAttributes['rel'] {
return (
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
)
}
</script>
<template>
@@ -29,18 +39,32 @@ defineProps<{
{{ heading }}
</h2>
<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>
<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="outline"
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>
<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"
>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { AnchorHTMLAttributes } from 'vue'
import Button from '../ui/button/Button.vue'
type Cta = {
label: string
href: string
target?: AnchorHTMLAttributes['target']
rel?: AnchorHTMLAttributes['rel']
}
type Visual = {
src: string
alt: string
width?: number
height?: number
}
const { visual, eyebrow, title, subtitle, primaryCta, secondaryCta } =
defineProps<{
visual?: Visual
eyebrow?: string
title: string
subtitle?: string
primaryCta: Cta
secondaryCta?: Cta
}>()
function resolveRel(cta: Cta): AnchorHTMLAttributes['rel'] {
return (
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
)
}
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<img
v-if="visual"
: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"
/>
<p
v-if="eyebrow"
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
>
{{ eyebrow }}
</p>
<h1
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ 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>

View File

@@ -8,6 +8,7 @@ const baseRoutes = {
cloudEnterprise: '/cloud/enterprise',
api: '/api',
gallery: '/gallery',
drops: '/drops',
about: '/about',
careers: '/careers',
customers: '/customers',

View File

@@ -4928,6 +4928,63 @@ 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: 'Subscribe to the live stream and get your questions answered 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': '试用工作流'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -0,0 +1,18 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.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} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -0,0 +1,18 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.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} />
<CtaSection locale={locale} />
</BaseLayout>

View File

@@ -0,0 +1,25 @@
<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>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import HeroCenter01 from '../../components/blocks/HeroCenter01.vue'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<HeroCenter01
:visual="{
src: '/affiliates/brand/comfy-amplified-logo.png',
alt: t('drops.hero.visualAlt', locale),
width: 632,
height: 632
}"
: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'
}"
/>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
// V1 fallback: point at the Comfy YouTube channel until content team supplies
// a dedicated event-registration URL (Google Form, Eventbrite, etc.).
const signUpHref = externalLinks.youtube
</script>
<template>
<div
class="bg-primary-comfy-plum text-primary-warm-white flex w-full flex-col items-center justify-center gap-2 px-6 py-3 text-center text-sm sm:flex-row sm:gap-4"
>
<p>{{ t('drops.banner.text', locale) }}</p>
<a
:href="signUpHref"
target="_blank"
rel="noopener noreferrer"
class="font-semibold uppercase underline underline-offset-4 hover:no-underline"
>
{{ t('drops.banner.cta', locale) }}
</a>
</div>
</template>

View File

@@ -69,24 +69,6 @@ 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({

View File

@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
import { test } from '@playwright/test'
import type { AppMode } from '@/utils/appMode'
import type { AppMode } from '@/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON

View File

@@ -32,10 +32,6 @@ 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')
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -4,6 +4,7 @@ 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
@@ -34,6 +35,56 @@ 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)
@@ -347,33 +398,34 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
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ード
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')
@@ -382,7 +434,9 @@ 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 }) => {

View File

@@ -10,16 +10,13 @@ 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', () => {
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
test('Node enforces minimum size', async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
@@ -31,15 +28,17 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(size![1]).toBeGreaterThanOrEqual(550)
})
test('Does not render hidden standard widgets in Vue mode', async ({
test('Width, height, and bg_color standard widgets are hidden', async ({
comfyPage
}) => {
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()
}
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])
})
})
@@ -789,49 +788,6 @@ 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'] },

View File

@@ -1,13 +1,13 @@
import { expect, mergeTests } from '@playwright/test'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
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(createCloudAssetsFixture([]), templateApiFixture)
const test = mergeTests(comfyPageFixture, templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop

View File

@@ -0,0 +1,26 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExtensionService } from '@/services/extensionService'
/**
* Cloud-only extension that enforces active subscription requirement
*/
useExtensionService().registerExtension({
name: 'Comfy.Cloud.Subscription',
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { requireActiveSubscription } = useBillingContext()
const checkSubscriptionStatus = () => {
if (!isLoggedIn.value) return
void requireActiveSubscription()
}
watch(() => isLoggedIn.value, checkSubscriptionStatus, {
immediate: true
})
}
})

View File

@@ -36,6 +36,10 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')
}
}
// Feedback button for cloud and nightly builds

View File

@@ -223,18 +223,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['film', 'FILM VFI', 'ckpt_name'],
// ---- Ultralytics YOLO detectors (ComfyUI-Impact-Pack) ----
// Intentionally NOT mapped to the asset-picker. The cloud asset-ingestion
// metadata for nested model folders (`ultralytics/bbox`, `ultralytics/segm`)
// still has the two known half-bugs described in #12075:
// 1. Tag lookup mismatch (cloud stores combined tags, picker queries split).
// 2. Submitted value mismatch (picker returns basenames, ingest expects
// subdirectory-prefixed `bbox/<file>` / `segm/<file>`).
// PR #12151 re-added the bbox/segm entries before either half was fixed,
// reintroducing the FaceDetailer breakage. Until BE-689 lands the cloud-side
// fixes, leave these disabled so the node falls back to the static combo
// populated from `/api/object_info`.
// ['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
// ['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
// ---- Mel-Band RoFormer audio separation (ComfyUI-MelBandRoFormer) ----
['diffusion_models', 'MelBandRoFormerModelLoader', 'model_name'],

View File

@@ -302,7 +302,6 @@ export const useLitegraphService = () => {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
if (inputSpec.hidden !== undefined) widget.hidden = inputSpec.hidden
if (dynamic) widget.tooltip = inputSpec.tooltip
}