mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-28 02:27:21 +00:00
Compare commits
40 Commits
codex/cove
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff893d2408 | ||
|
|
4a2393be48 | ||
|
|
a451a90868 | ||
|
|
be102899d7 | ||
|
|
abd1a6f10a | ||
|
|
c16f10b49e | ||
|
|
64253de713 | ||
|
|
b4ae6344d7 | ||
|
|
feaa4ce82f | ||
|
|
f5d059b720 | ||
|
|
e2c670bbc3 | ||
|
|
dfcb336499 | ||
|
|
caabebe145 | ||
|
|
7d3d8ce63f | ||
|
|
506e33e7dd | ||
|
|
7376402fc6 | ||
|
|
be38f14619 | ||
|
|
13b42d9b59 | ||
|
|
e604c85b88 | ||
|
|
7ae3ad936c | ||
|
|
7b83228cdd | ||
|
|
d26f578c81 | ||
|
|
cfbc378df3 | ||
|
|
db085eb7a1 | ||
|
|
48d429bd13 | ||
|
|
6eaad99502 | ||
|
|
2ced8a25d4 | ||
|
|
9209a4b923 | ||
|
|
90cb8021df | ||
|
|
5a01c5b3b4 | ||
|
|
e3049e7c31 | ||
|
|
87e84e7280 | ||
|
|
67009dcda2 | ||
|
|
026b2c4795 | ||
|
|
d60260ac3c | ||
|
|
0c89f5a3a7 | ||
|
|
f19597ce81 | ||
|
|
988dc71955 | ||
|
|
da55529d23 | ||
|
|
52d430d1b6 |
@@ -33,15 +33,15 @@ Flag:
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
### Dedicated Stores and Data/Behavior Separation
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
reviews:
|
||||
profile: assertive
|
||||
high_level_summary: false
|
||||
request_changes_workflow: true
|
||||
auto_review:
|
||||
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -133,3 +133,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
|
||||
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
|
||||
-e '1x00000000000000000000AA' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
|
||||
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
|
||||
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Turnstile sitekey references found'
|
||||
|
||||
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -88,9 +88,9 @@ jobs:
|
||||
- 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*' \
|
||||
'assets/images/*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
@@ -121,7 +121,8 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
5
.github/workflows/pr-backport.yaml
vendored
5
.github/workflows/pr-backport.yaml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist a token with `workflow` scope so the backport push can
|
||||
# include changes to .github/workflows/**. The default GITHUB_TOKEN
|
||||
# is refused by GitHub when a push creates/updates workflow files,
|
||||
# which silently aborted the whole job (see PR #12804 backport).
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,8 @@ module.exports = defineConfig({
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
'fa',
|
||||
'he'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
@@ -37,5 +38,11 @@ module.exports = defineConfig({
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
|
||||
IMPORTANT Hebrew Translation Guidelines:
|
||||
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
|
||||
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
|
||||
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
|
||||
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
|
||||
`
|
||||
})
|
||||
|
||||
@@ -83,6 +83,16 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/useFeatureFlags',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -179,6 +179,9 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
23. Favor pure functions (especially testable ones)
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
26. Do not add alias helpers whose implementation is just a single-line call to another function
|
||||
- Bad: `function id(value) { return nodeId(value) }`
|
||||
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
|
||||
|
||||
## Design Standards
|
||||
|
||||
@@ -246,7 +249,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
await expect(downloadBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/download/windows/nsis/x64'
|
||||
)
|
||||
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
'a[href="https://comfy.org/download/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
231
apps/website/e2e/launches.spec.ts
Normal file
231
apps/website/e2e/launches.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
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 = '/launches'
|
||||
const PATH_ZH = '/zh-CN/launches'
|
||||
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('launches.hero.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ctaSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function dropsSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.section.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Launches landing — desktop @smoke', () => {
|
||||
test('renders the configured title at /launches', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page).toHaveTitle(t('launches.page.title', 'en'))
|
||||
})
|
||||
|
||||
test('renders the localized title at /zh-CN/launches', async ({ page }) => {
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page).toHaveTitle(t('launches.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('launches.hero.title', 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.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('launches.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('launches.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('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('launches.cta.heading', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const primary = section.getByRole('link', {
|
||||
name: t('launches.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('launches.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('launches.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)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
|
||||
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
|
||||
return firstWidth - fifthWidth
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Launches 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 viewport = page.viewportSize()
|
||||
expect(viewport, 'viewport size').not.toBeNull()
|
||||
|
||||
await expect
|
||||
.poll(async () => (await cards.nth(0).boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(viewport!.width * 0.7)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const firstBox = await cards.nth(0).boundingBox()
|
||||
const secondBox = await cards.nth(1).boundingBox()
|
||||
if (!firstBox || !secondBox) return false
|
||||
return secondBox.y >= firstBox.y + firstBox.height
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('closing CTA heading stays within viewport width', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
const heading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', 'en')
|
||||
})
|
||||
await heading.scrollIntoViewIfNeeded()
|
||||
await expect(heading).toBeVisible()
|
||||
|
||||
const box = await heading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
const viewport = page.viewportSize()
|
||||
expect(viewport, 'viewport size').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
@@ -12,10 +16,11 @@ type TermsLink = {
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
secondaryCta?: Cta
|
||||
termsLink?: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -24,23 +29,37 @@ defineProps<{
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ 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="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>
|
||||
|
||||
<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"
|
||||
>
|
||||
|
||||
166
apps/website/src/components/blocks/HeroLivestream01.vue
Normal file
166
apps/website/src/components/blocks/HeroLivestream01.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<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>
|
||||
<slot v-else name="visual">
|
||||
<img
|
||||
v-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"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<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>
|
||||
@@ -6,6 +6,7 @@ import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BrandButtonVariants } from './brandButton.variants'
|
||||
import { brandButtonVariants } from './brandButton.variants'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
const props = defineProps<{
|
||||
href?: string
|
||||
@@ -16,9 +17,8 @@ const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
const resolvedRel = computed(() =>
|
||||
resolveRel({ rel: props.rel, target: props.target })
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{ label: t('nav.launches', locale), href: routes.launches },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
|
||||
@click="captureDownloadClick(btn.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
:class="cn(badgeVariants({ size, variant }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
|
||||
@@ -4,15 +4,16 @@ 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: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
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]'
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
category: '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'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,7 +8,8 @@ export const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
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',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
@@ -17,6 +18,8 @@ export const buttonVariants = cva(
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
|
||||
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",
|
||||
underlineLink:
|
||||
"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",
|
||||
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'
|
||||
|
||||
22
apps/website/src/components/ui/card/Card.vue
Normal file
22
apps/website/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
14
apps/website/src/components/ui/card/CardContent.vue
Normal file
14
apps/website/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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('px-6', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
19
apps/website/src/components/ui/card/CardDescription.vue
Normal file
19
apps/website/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
14
apps/website/src/components/ui/card/CardFooter.vue
Normal file
14
apps/website/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
14
apps/website/src/components/ui/card/CardHeader.vue
Normal file
14
apps/website/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
22
apps/website/src/components/ui/card/CardTitle.vue
Normal file
22
apps/website/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
windows: 'https://comfy.org/download/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ interface HeroLogoConfig {
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
svgMarkup: string
|
||||
fitAxis: 'width' | 'height'
|
||||
targetSize: number
|
||||
respectReducedMotion: boolean
|
||||
baseUrl: string
|
||||
fadeInDurationMs: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
@@ -34,19 +40,25 @@ const DEFAULTS: HeroLogoConfig = {
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
slideDuration: 0.4,
|
||||
svgMarkup: SVG_MARKUP,
|
||||
fitAxis: 'height',
|
||||
targetSize: 3,
|
||||
respectReducedMotion: true,
|
||||
baseUrl: BASE_URL,
|
||||
fadeInDurationMs: 0
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
function buildImageUrls(baseUrl: string): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
return `${baseUrl}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
function parseShapes(markup: string): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const svgData = loader.parse(markup)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
@@ -85,7 +97,8 @@ export function useHeroLogo(
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
if (!container || (cfg.respectReducedMotion && prefersReducedMotion()))
|
||||
return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
@@ -102,6 +115,9 @@ export function useHeroLogo(
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
if (cfg.fadeInDurationMs > 0) {
|
||||
renderer.domElement.style.transition = `opacity ${cfg.fadeInDurationMs}ms ease`
|
||||
}
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
@@ -126,24 +142,36 @@ export function useHeroLogo(
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const shapes = parseShapes(cfg.svgMarkup)
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const bb = tempGeo.boundingBox
|
||||
if (!bb) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
const fitExtent =
|
||||
cfg.fitAxis === 'width' ? bb.max.x - bb.min.x : bb.max.y - bb.min.y
|
||||
if (fitExtent <= 0) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const scaleFactor = cfg.targetSize / fitExtent
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const urls = buildImageUrls(cfg.baseUrl)
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
void loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const baseRoutes = {
|
||||
cloudEnterprise: '/cloud/enterprise',
|
||||
api: '/api',
|
||||
gallery: '/gallery',
|
||||
launches: '/launches',
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
@@ -59,6 +60,7 @@ export const externalLinks = {
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
|
||||
248
apps/website/src/data/drops.ts
Normal file
248
apps/website/src/data/drops.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
// 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' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
|
||||
}
|
||||
},
|
||||
{
|
||||
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' }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -180,6 +180,11 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.launches', locale),
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -1849,6 +1849,7 @@ const translations = {
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
@@ -4928,6 +4929,70 @@ const translations = {
|
||||
'affiliate.cta.termsLabel': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': '阅读联盟计划条款'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — head metadata
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.page.title': {
|
||||
en: 'ComfyUI Live Demo & Q&A — June 29 Launch Livestream',
|
||||
'zh-CN': 'ComfyUI 直播演示与问答 — 6 月 29 日发布直播'
|
||||
},
|
||||
'launches.page.description': {
|
||||
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See what’s new across desktop, cloud, and community, and get your questions answered.',
|
||||
'zh-CN':
|
||||
'6 月 29 日加入 ComfyUI 直播,观看实操产品演示并参与实时问答。了解桌面、云端和社区的最新内容,并获得解答。'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — hero section
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.hero.title': {
|
||||
en: 'Everything new in ComfyUI',
|
||||
'zh-CN': 'ComfyUI 全新内容'
|
||||
},
|
||||
'launches.hero.primary': {
|
||||
en: 'Download Desktop',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'launches.hero.secondary': {
|
||||
en: 'Launch Cloud',
|
||||
'zh-CN': '启动云端'
|
||||
},
|
||||
'launches.hero.visualAlt': {
|
||||
en: 'Comfy',
|
||||
'zh-CN': 'Comfy'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.cta.heading': {
|
||||
en: 'Everything Comfy ships. All in one place.',
|
||||
'zh-CN': 'Comfy 的全部内容,一处尽享。'
|
||||
},
|
||||
'launches.cta.primary': {
|
||||
en: 'Open Comfy Cloud',
|
||||
'zh-CN': '打开 Comfy Cloud'
|
||||
},
|
||||
'launches.cta.secondary': {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — launches grid
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.section.title': {
|
||||
en: 'Latest Launches',
|
||||
'zh-CN': '最新发布'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
20
apps/website/src/pages/launches.astro
Normal file
20
apps/website/src/pages/launches.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
</BaseLayout>
|
||||
20
apps/website/src/pages/zh-CN/launches.astro
Normal file
20
apps/website/src/pages/zh-CN/launches.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
</BaseLayout>
|
||||
25
apps/website/src/templates/drops/CtaSection.vue
Normal file
25
apps/website/src/templates/drops/CtaSection.vue
Normal 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('launches.cta.heading', locale)"
|
||||
:primary-cta="{
|
||||
label: t('launches.cta.primary', locale),
|
||||
href: externalLinks.cloud,
|
||||
target: '_blank'
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('launches.cta.secondary', locale),
|
||||
href: externalLinks.workflows,
|
||||
target: '_blank'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
76
apps/website/src/templates/drops/DropCard.vue
Normal file
76
apps/website/src/templates/drops/DropCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { Drop } from '../../data/drops'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import Badge from '../../components/ui/badge/Badge.vue'
|
||||
|
||||
import ButtonPill from '../../components/ui/button-pill/ButtonPill.vue'
|
||||
import Card from '../../components/ui/card/Card.vue'
|
||||
import CardContent from '../../components/ui/card/CardContent.vue'
|
||||
import CardDescription from '../../components/ui/card/CardDescription.vue'
|
||||
import CardFooter from '../../components/ui/card/CardFooter.vue'
|
||||
import CardHeader from '../../components/ui/card/CardHeader.vue'
|
||||
import CardTitle from '../../components/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="category">
|
||||
{{ 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>
|
||||
29
apps/website/src/templates/drops/DropsSection.vue
Normal file
29
apps/website/src/templates/drops/DropsSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import DropCard from './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('launches.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>
|
||||
35
apps/website/src/templates/drops/HeroSection.vue
Normal file
35
apps/website/src/templates/drops/HeroSection.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import HeroLivestream01 from '../../components/blocks/HeroLivestream01.vue'
|
||||
import LaunchesHeroLogo from './LaunchesHeroLogo.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroLivestream01
|
||||
:title="t('launches.hero.title', locale)"
|
||||
:primary-cta="{
|
||||
label: t('launches.hero.primary', locale),
|
||||
href: routes.download
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('launches.hero.secondary', locale),
|
||||
href: externalLinks.cloud,
|
||||
target: '_blank'
|
||||
}"
|
||||
:youtube-video-id="livestream.youtubeVideoId"
|
||||
:start-date-time="livestream.startDateTime"
|
||||
:end-date-time="livestream.endDateTime"
|
||||
>
|
||||
<template #visual>
|
||||
<LaunchesHeroLogo :label="t('launches.hero.visualAlt', locale)" />
|
||||
</template>
|
||||
</HeroLivestream01>
|
||||
</template>
|
||||
55
apps/website/src/templates/drops/LaunchesHeroLogo.vue
Normal file
55
apps/website/src/templates/drops/LaunchesHeroLogo.vue
Normal file
File diff suppressed because one or more lines are too long
61
apps/website/src/templates/drops/SubscribeBanner.vue
Normal file
61
apps/website/src/templates/drops/SubscribeBanner.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" 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('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
5
apps/website/src/templates/drops/livestream.ts
Normal file
5
apps/website/src/templates/drops/livestream.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const livestream = {
|
||||
youtubeVideoId: 'yo7b_zHd20g',
|
||||
startDateTime: '2026-06-29T15:00:00Z',
|
||||
endDateTime: '2026-07-02T17:15:00Z'
|
||||
} as const
|
||||
18
apps/website/src/utils/cta.test.ts
Normal file
18
apps/website/src/utils/cta.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveRel } from './cta'
|
||||
|
||||
describe('resolveRel', () => {
|
||||
it('prefers an explicit rel over the target-derived default', () => {
|
||||
expect(resolveRel({ rel: 'nofollow', target: '_blank' })).toBe('nofollow')
|
||||
})
|
||||
|
||||
it('adds noopener noreferrer for _blank targets', () => {
|
||||
expect(resolveRel({ target: '_blank' })).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('returns undefined for non-blank targets', () => {
|
||||
expect(resolveRel({ target: '_self' })).toBeUndefined()
|
||||
expect(resolveRel({})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
10
apps/website/src/utils/cta.ts
Normal file
10
apps/website/src/utils/cta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
export type VideoSource = {
|
||||
type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
format: VideoFormat
|
||||
|
||||
@@ -14,6 +14,24 @@
|
||||
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
|
||||
],
|
||||
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
|
||||
},
|
||||
{
|
||||
"source": "/payment/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Robots-Tag",
|
||||
"value": "noindex"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/:locale/payment/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Robots-Tag",
|
||||
"value": "noindex"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "test-missing-model-nested-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [10, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"title": "Resolved Shared Outer Subgraph",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["resolved_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"title": "Outer Subgraph with Promoted Missing Model",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "outer-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [600, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "outer-ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "inner-subgraph-with-promoted-missing-model",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 2,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inner-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "inner-ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
/**
|
||||
@@ -43,7 +44,7 @@ export class VueNodeHelpers {
|
||||
.locator('.lg-slot--input')
|
||||
.filter({
|
||||
has: this.page.locator(
|
||||
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
|
||||
`[data-slot-key="${getSlotKey(toNodeId(nodeId), slotIndex, true)}"]`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -251,14 +252,18 @@ export class VueNodeHelpers {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) return false
|
||||
|
||||
return await this.page.evaluate((key) => {
|
||||
const [nodeId, type, slotId] = key.split('-')
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
const [rawNodeId, type, slotId] = key.split('-')
|
||||
const nodeId = toNodeId(rawNodeId)
|
||||
return await this.page.evaluate(
|
||||
([nodeId, type, slotId]) => {
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)]?.links?.length
|
||||
},
|
||||
[nodeId, type, slotId] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly sidebarContent: Locator
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly essentialsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
@@ -101,8 +101,8 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
super(page, 'node-library')
|
||||
this.searchInput = page.getByPlaceholder('Search...')
|
||||
this.sidebarContent = page.locator('.sidebar-content-container')
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.allTab = this.getTab('All nodes')
|
||||
this.essentialsTab = this.getTab('Essentials')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Member,
|
||||
Plan,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
// `/api/features` is the remote-config source: production builds resolve the
|
||||
// workspaces flag from it (the `ff:` localStorage override is dev-only).
|
||||
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
|
||||
team_workspaces_enabled: true
|
||||
}
|
||||
|
||||
export const TEAM_WORKSPACE: WorkspaceWithRole = {
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
subscription_tier: 'PRO'
|
||||
}
|
||||
|
||||
export const CREATOR: Member = {
|
||||
id: 'u-liz',
|
||||
name: 'Liz',
|
||||
email: 'liz@test.comfy.org',
|
||||
joined_at: '2025-01-01T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}
|
||||
|
||||
// Identity must match the CloudAuthHelper mock user so this row counts as
|
||||
// "(You)".
|
||||
export const VIEWER: Member = {
|
||||
id: 'u-me',
|
||||
name: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JANE: Member = {
|
||||
id: 'u-jane',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JOHN: Member = {
|
||||
id: 'u-john',
|
||||
name: 'John',
|
||||
email: 'john@test.comfy.org',
|
||||
joined_at: '2025-01-04T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const DEFAULT_TEAM_MEMBERS: Member[] = [
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN
|
||||
]
|
||||
|
||||
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly',
|
||||
billing_status: 'paid',
|
||||
has_funds: true,
|
||||
renewal_date: '2099-02-20T00:00:00Z'
|
||||
}
|
||||
|
||||
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
|
||||
// the whole role-management UI.
|
||||
export const TEAM_PRO_PLAN: Plan = {
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 21100,
|
||||
max_seats: 30,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 4,
|
||||
total_cost_cents: 40000,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import {
|
||||
DEFAULT_TEAM_MEMBERS,
|
||||
TEAM_BILLING_STATUS,
|
||||
TEAM_PRO_PLAN,
|
||||
TEAM_WORKSPACE,
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface MemberMockState {
|
||||
members: Member[]
|
||||
patches: RoleChangeRequest[]
|
||||
}
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked workspace + billing endpoints so
|
||||
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
|
||||
* to reach the OSS devtools backend during setup).
|
||||
*
|
||||
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
|
||||
* `patches` records every role-change request for assertion.
|
||||
*/
|
||||
export class CloudWorkspaceMockHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async setup(
|
||||
members: Member[] = DEFAULT_TEAM_MEMBERS
|
||||
): Promise<MemberMockState> {
|
||||
const state = await this.mockBoot(members)
|
||||
await new CloudAuthHelper(this.page).mockAuth()
|
||||
await this.page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
private async mockBoot(members: Member[]): Promise<MemberMockState> {
|
||||
const state: MemberMockState = {
|
||||
members: members.map((m) => ({ ...m })),
|
||||
patches: []
|
||||
}
|
||||
const { page } = this
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// A non-empty settings payload with TutorialCompleted marks the user as
|
||||
// returning, so the new-user Templates dialog never auto-opens to block the
|
||||
// Settings button. Errors tab off suppresses the model-folder 401 toast.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/workspace/members**', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'PATCH') {
|
||||
const url = request.url()
|
||||
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
|
||||
const { role } = request.postDataJSON() as { role: Member['role'] }
|
||||
state.patches.push({ url, role })
|
||||
const member = state.members.find((m) => m.id === id)
|
||||
if (member) member.role = role
|
||||
// Echo the updated row like the real BE; the store merges only the role
|
||||
// locally, so the response body shape is not load-bearing.
|
||||
return route.fulfill(jsonRoute(member))
|
||||
}
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
members: state.members,
|
||||
pagination: { offset: 0, limit: 50, total: state.members.length }
|
||||
})
|
||||
)
|
||||
})
|
||||
await page.route('**/api/workspace/invites', (r) =>
|
||||
r.fulfill(jsonRoute({ invites: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000,
|
||||
prepaid_balance_micros: 1000
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
|
||||
)
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import type {
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -42,11 +41,12 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selectedNodeIds = await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
return Object.keys(selected)
|
||||
})
|
||||
return selectedNodeIds.map(toNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,8 +114,8 @@ export class NodeOperationsHelper {
|
||||
return this.getNodeRefById(id)
|
||||
}
|
||||
|
||||
async getNodeRefById(id: NodeId): Promise<NodeReference> {
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
async getNodeRefById(id: SerializedNodeId): Promise<NodeReference> {
|
||||
return new NodeReference(toNodeId(id), this.comfyPage)
|
||||
}
|
||||
|
||||
async getNodeRefsByType(
|
||||
@@ -136,7 +136,7 @@ export class NodeOperationsHelper {
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class NodeOperationsHelper {
|
||||
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
}, title)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
@@ -45,9 +48,9 @@ async function orbitDragFromCanvasCenter(
|
||||
|
||||
export class Preview3DPipelineContext {
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly loadNodeId = '1'
|
||||
static readonly loadNodeId = toNodeId(1)
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly previewNodeId = '2'
|
||||
static readonly previewNodeId = toNodeId(2)
|
||||
|
||||
readonly load3d: Load3DHelper
|
||||
readonly preview3d: Load3DHelper
|
||||
@@ -61,9 +64,9 @@ export class Preview3DPipelineContext {
|
||||
)
|
||||
}
|
||||
|
||||
async getModelFileWidgetValue(nodeId: string): Promise<string> {
|
||||
async getModelFileWidgetValue(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.widgets) return ''
|
||||
const w = node.widgets.find((x) => x.name === 'model_file')
|
||||
const v = w?.value
|
||||
@@ -71,9 +74,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getLastTimeModelFile(nodeId: string): Promise<string> {
|
||||
async getLastTimeModelFile(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return ''
|
||||
const v = (node.properties as Record<string, unknown>)[
|
||||
'Last Time Model File'
|
||||
@@ -82,9 +85,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
|
||||
async getCameraStateFromProperties(nodeId: NodeId): Promise<unknown> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return null
|
||||
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
|
||||
if (cfg === null || typeof cfg !== 'object') return null
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
@@ -549,6 +550,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
static getTextSlotPosition(page: Page, nodeId: string) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
@@ -565,7 +567,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
static async expectWidgetBelowHeader(
|
||||
|
||||
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* Minimal valid billing shapes so the billing facade resolves while a
|
||||
* subscription dialog mounts. Active personal sub with zero balance.
|
||||
*/
|
||||
export async function mockBilling(page: Page) {
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'pro',
|
||||
subscription_duration: 'MONTHLY',
|
||||
billing_status: 'paid'
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute({ is_active: false }))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
}
|
||||
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
interface CloudBootOptions {
|
||||
/** Remote-config payload for `/api/features` (enables the flags under test). */
|
||||
features: RemoteConfig
|
||||
/** Body for `/api/settings` (defaults to `{}`). */
|
||||
settings?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
|
||||
* working app without falling through to the OSS devtools backend. Specs layer
|
||||
* their own feature- or flow-specific routes on top.
|
||||
*/
|
||||
export async function mockCloudBoot(
|
||||
page: Page,
|
||||
{ features, settings = {} }: CloudBootOptions
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
|
||||
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
|
||||
* original-owner gate matches against the members self-row.
|
||||
*/
|
||||
export async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
|
||||
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
|
||||
* drift against the real API shape.
|
||||
*/
|
||||
export function jsonRoute<T>(body: T) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
136
browser_tests/fixtures/utils/objectInfo.ts
Normal file
136
browser_tests/fixtures/utils/objectInfo.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import {
|
||||
getComboSpecComboOptions,
|
||||
isComboInputSpec,
|
||||
isComboInputSpecV1
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2,
|
||||
ComfyNodeDef,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
|
||||
type ComboInput = ComboInputSpec | ComboInputSpecV2
|
||||
|
||||
const OBJECT_INFO_ROUTE = '**/object_info'
|
||||
|
||||
function getRequiredInput(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string
|
||||
): InputSpec {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!input) {
|
||||
throw new Error(`Missing input ${nodeType}.${inputName}`)
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
function getComboInput(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string
|
||||
): ComboInput {
|
||||
const input = getRequiredInput(objectInfo, nodeType, inputName)
|
||||
if (isComboInputSpec(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
export function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: ReadonlyArray<string | number>
|
||||
): void {
|
||||
const input = getComboInput(objectInfo, nodeType, inputName)
|
||||
const nextValues = [...values]
|
||||
|
||||
if (isComboInputSpecV1(input)) {
|
||||
input[0] = nextValues
|
||||
return
|
||||
}
|
||||
|
||||
input[1] = { ...input[1], options: nextValues }
|
||||
}
|
||||
|
||||
export function appendComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: ReadonlyArray<string | number>
|
||||
): void {
|
||||
const input = getComboInput(objectInfo, nodeType, inputName)
|
||||
setComboInputOptions(objectInfo, nodeType, inputName, [
|
||||
...getComboSpecComboOptions(input),
|
||||
...values
|
||||
])
|
||||
}
|
||||
|
||||
export async function routeObjectInfoFromSetupApi(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void | Promise<void>
|
||||
): Promise<() => Promise<void>> {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
let objectInfo: ObjectInfoResponse
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await customize?.(objectInfo)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
|
||||
return async () => {
|
||||
if (page.isClosed()) return
|
||||
await page.unroute(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
|
||||
}
|
||||
}
|
||||
425
browser_tests/fixtures/utils/promotedMissingModel.ts
Normal file
425
browser_tests/fixtures/utils/promotedMissingModel.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
|
||||
|
||||
interface PromotedMissingModelWorkflow {
|
||||
workflowName: string
|
||||
hostNodeId: number
|
||||
hostNodeTitle: string
|
||||
sharedDefinitionSiblingHostNodeId?: number
|
||||
sharedDefinitionSiblingHostNodeTitle?: string
|
||||
}
|
||||
|
||||
type RootWorkflowNode = {
|
||||
id: number | string
|
||||
widgets_values?: unknown[] | Record<string, unknown>
|
||||
}
|
||||
|
||||
type RootWorkflowData = ComfyWorkflowJSON & {
|
||||
nodes?: RootWorkflowNode[]
|
||||
}
|
||||
|
||||
export const NESTED_PROMOTED_MISSING_MODEL_WORKFLOW: PromotedMissingModelWorkflow =
|
||||
{
|
||||
workflowName: 'missing/missing_model_nested_promoted_widget',
|
||||
hostNodeId: 4,
|
||||
hostNodeTitle: 'Outer Subgraph with Promoted Missing Model',
|
||||
sharedDefinitionSiblingHostNodeId: 3,
|
||||
sharedDefinitionSiblingHostNodeTitle: 'Resolved Shared Outer Subgraph'
|
||||
}
|
||||
|
||||
export function getMissingModelLabel(group: Locator, modelName: string) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
export async function expectSingleMissingModelReference(
|
||||
group: Locator,
|
||||
modelName: string
|
||||
) {
|
||||
await expectMissingModelReferenceCount(group, modelName, 1)
|
||||
}
|
||||
|
||||
export async function expectMissingModelReferenceCount(
|
||||
group: Locator,
|
||||
modelName: string,
|
||||
count: number
|
||||
) {
|
||||
await expect(getMissingModelLabel(group, modelName)).toHaveCount(1)
|
||||
const badge = group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
if (count === 1) {
|
||||
await expect(badge).toBeHidden()
|
||||
return
|
||||
}
|
||||
await expect(badge).toBeVisible()
|
||||
await expect(badge).toHaveText(String(count))
|
||||
}
|
||||
|
||||
export async function loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
): Promise<Locator> {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, workflow.workflowName)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectSingleMissingModelReference(missingModelGroup, modelName)
|
||||
return missingModelGroup
|
||||
}
|
||||
|
||||
export async function loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
hostValues: Record<number, string>,
|
||||
modelName: string,
|
||||
expectedReferenceCount: number
|
||||
): Promise<Locator> {
|
||||
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, hostValues)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectMissingModelReferenceCount(
|
||||
missingModelGroup,
|
||||
modelName,
|
||||
expectedReferenceCount
|
||||
)
|
||||
return missingModelGroup
|
||||
}
|
||||
|
||||
export async function expectNoMissingModelUi(comfyPage: ComfyPage) {
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
export async function selectVueComboPromotedModelByTitle(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string,
|
||||
modelName: string
|
||||
) {
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
nodeTitle,
|
||||
PROMOTED_MODEL_WIDGET_NAME,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectVueAssetPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
currentModelName: string,
|
||||
modelName: string
|
||||
) {
|
||||
await selectModelFromFormDropdown(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getNodeByTitle(workflow.hostNodeTitle),
|
||||
currentModelName,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectSectionComboPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
) {
|
||||
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
|
||||
const combo = panel.contentArea.getByRole('combobox', {
|
||||
name: PROMOTED_MODEL_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await combo.click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: modelName, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
export async function selectSectionAssetPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
currentModelName: string,
|
||||
modelName: string
|
||||
) {
|
||||
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
|
||||
await selectModelFromFormDropdown(
|
||||
comfyPage,
|
||||
panel.contentArea,
|
||||
currentModelName,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function setLegacyPromotedComboModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ hostNodeId, widgetName, value }) => {
|
||||
type LegacyPromotedWidget = {
|
||||
name?: string
|
||||
value?: unknown
|
||||
callback?: (value: string) => void
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
type LegacyPromotedNode = {
|
||||
onWidgetChanged?: (
|
||||
name: string,
|
||||
newValue: string,
|
||||
oldValue: unknown,
|
||||
widget: LegacyPromotedWidget
|
||||
) => void
|
||||
widgets?: LegacyPromotedWidget[]
|
||||
}
|
||||
type LegacyPromotedGraph = {
|
||||
getNodeById: (nodeId: number) => LegacyPromotedNode | undefined
|
||||
}
|
||||
|
||||
const currentGraph = window.app?.graph as LegacyPromotedGraph | undefined
|
||||
const hostNode: LegacyPromotedNode | undefined =
|
||||
currentGraph?.getNodeById(hostNodeId)
|
||||
if (!hostNode) {
|
||||
throw new Error(`Expected subgraph host node ${hostNodeId}`)
|
||||
}
|
||||
|
||||
const widget = hostNode.widgets?.find(
|
||||
(entry) => entry.name === widgetName
|
||||
) as LegacyPromotedWidget | undefined
|
||||
if (!widget) {
|
||||
throw new Error(`Expected host ${widgetName} widget`)
|
||||
}
|
||||
|
||||
const oldValue = widget.value
|
||||
if (widget.setValue) {
|
||||
widget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
hostNode.onWidgetChanged?.(
|
||||
widget.name ?? widgetName,
|
||||
value,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
},
|
||||
{
|
||||
hostNodeId: workflow.hostNodeId,
|
||||
widgetName: PROMOTED_MODEL_WIDGET_NAME,
|
||||
value: modelName
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectLegacyPromotedAssetModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
assetId: string
|
||||
) {
|
||||
await clickLegacyHostPromotedWidget(comfyPage, workflow)
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
const assetCard = modal.locator(`[data-asset-id="${assetId}"]`)
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use', exact: true }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
}
|
||||
|
||||
export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
expectedStaleInteriorWidgets: Array<{
|
||||
subgraphNodeIdToEnter: string
|
||||
nodeTitle: string
|
||||
}>,
|
||||
resolvedModelName: string,
|
||||
staleModelName: string
|
||||
) {
|
||||
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, {
|
||||
[workflow.hostNodeId]: resolvedModelName
|
||||
})
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle(workflow.hostNodeTitle)
|
||||
.getByRole('combobox', { name: PROMOTED_MODEL_WIDGET_NAME, exact: true })
|
||||
await expect(promotedModelCombo).toContainText(resolvedModelName)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
|
||||
for (const step of expectedStaleInteriorWidgets) {
|
||||
await enterSubgraphForStaleInteriorCheck(
|
||||
comfyPage,
|
||||
step.subgraphNodeIdToEnter
|
||||
)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const staleCombo = node.getByRole('combobox', {
|
||||
name: PROMOTED_MODEL_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await expect(
|
||||
staleCombo,
|
||||
`${step.nodeTitle} should expose the stale linked interior widget`
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
staleCombo,
|
||||
`${step.nodeTitle} should keep the stale interior value`
|
||||
).toContainText(staleModelName)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
}
|
||||
|
||||
async function openHostNodeParametersPanel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow
|
||||
): Promise<PropertiesPanelHelper> {
|
||||
await comfyPage.vueNodes.selectNode(String(workflow.hostNodeId))
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await panel.switchToTab('Parameters')
|
||||
return panel
|
||||
}
|
||||
|
||||
async function loadPromotedMissingModelWithHostValues(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
hostValues: Record<number, string>
|
||||
) {
|
||||
const graphData = readPromotedMissingModelWorkflow(workflow.workflowName)
|
||||
for (const [hostNodeId, value] of Object.entries(hostValues)) {
|
||||
setRootHostWidgetValue(graphData, Number(hostNodeId), value)
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadGraphData(graphData)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
|
||||
function readPromotedMissingModelWorkflow(workflowName: string) {
|
||||
return JSON.parse(
|
||||
readFileSync(assetPath(`${workflowName}.json`), 'utf-8')
|
||||
) as RootWorkflowData
|
||||
}
|
||||
|
||||
function setRootHostWidgetValue(
|
||||
graphData: RootWorkflowData,
|
||||
hostNodeId: number,
|
||||
value: string
|
||||
) {
|
||||
const hostNode = graphData.nodes?.find(
|
||||
(node) => Number(node.id) === hostNodeId
|
||||
)
|
||||
if (!hostNode) throw new Error(`Expected host node ${hostNodeId}`)
|
||||
|
||||
if (Array.isArray(hostNode.widgets_values)) {
|
||||
hostNode.widgets_values[0] = value
|
||||
return
|
||||
}
|
||||
|
||||
hostNode.widgets_values = {
|
||||
...(hostNode.widgets_values ?? {}),
|
||||
[PROMOTED_MODEL_WIDGET_NAME]: value
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModelFromFormDropdown(
|
||||
comfyPage: ComfyPage,
|
||||
root: Locator,
|
||||
currentModelName: string,
|
||||
nextModelName: string
|
||||
) {
|
||||
const trigger = root
|
||||
.getByRole('button', { name: currentModelName, exact: true })
|
||||
.first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByText(nextModelName, { exact: true }).click()
|
||||
await expect(menu).toBeHidden()
|
||||
}
|
||||
|
||||
async function clickLegacyHostPromotedWidget(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow
|
||||
) {
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(workflow.hostNodeId)
|
||||
await hostNode.centerOnNode()
|
||||
const widget = await hostNode.getWidgetByName(PROMOTED_MODEL_WIDGET_NAME)
|
||||
await widget.click()
|
||||
}
|
||||
|
||||
async function enterSubgraphForStaleInteriorCheck(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
) {
|
||||
const numericNodeId = Number(nodeId)
|
||||
if (Number.isNaN(numericNodeId)) {
|
||||
throw new Error(`Expected numeric subgraph node id, got ${nodeId}`)
|
||||
}
|
||||
|
||||
const normalizedNodeId = String(numericNodeId)
|
||||
const enterButton =
|
||||
comfyPage.vueNodes.getSubgraphEnterButton(normalizedNodeId)
|
||||
if ((await enterButton.count()) > 0) {
|
||||
await comfyPage.vueNodes.enterSubgraph(normalizedNodeId)
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate((targetNodeId) => {
|
||||
const graph = window.app?.canvas.graph
|
||||
const node = graph?.getNodeById(targetNodeId)
|
||||
if (!node?.isSubgraphNode()) {
|
||||
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
|
||||
}
|
||||
window.app!.canvas.setGraph(node.subgraph)
|
||||
}, toNodeId(normalizedNodeId))
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
@@ -44,6 +45,7 @@ export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
@@ -91,7 +93,7 @@ export async function getPromotedWidgets(
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
localNodeId
|
||||
)
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
|
||||
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
export function workspace(
|
||||
type: 'personal' | 'team',
|
||||
role: 'owner' | 'member'
|
||||
): WorkspaceWithRole {
|
||||
return {
|
||||
id: `ws-${type}`,
|
||||
name: type === 'team' ? 'My Team' : 'Personal Workspace',
|
||||
type,
|
||||
role,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
export function member(
|
||||
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
|
||||
): Member {
|
||||
return {
|
||||
id: `user-${overrides.email}`,
|
||||
name: overrides.email,
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
is_original_owner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
*/
|
||||
export async function mockWorkspace(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
*
|
||||
* The repointed surfaces (avatar popover tier badge / balance, free-tier
|
||||
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
|
||||
* selects its backend by flag: `team_workspaces_enabled: false` routes through
|
||||
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
|
||||
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
|
||||
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// The workspace `/api/billing/status` shape mirrors the legacy subscription
|
||||
// status; map the fields so a single test fixture drives both backends.
|
||||
const toWorkspaceStatus = (
|
||||
s: CloudSubscriptionStatusResponse
|
||||
): BillingStatusResponse => ({
|
||||
is_active: s.is_active ?? false,
|
||||
subscription_tier: s.subscription_tier ?? undefined,
|
||||
subscription_duration: s.subscription_duration ?? undefined,
|
||||
renewal_date: s.renewal_date ?? undefined,
|
||||
cancel_at: s.end_date ?? undefined,
|
||||
has_funds: s.has_fund ?? true
|
||||
})
|
||||
|
||||
const mockBalance: BillingBalanceResponse = {
|
||||
amount_micros: 6000, // -> 12,660 credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000
|
||||
}
|
||||
|
||||
async function mockCloudBoot(
|
||||
page: Page,
|
||||
subscriptionStatus: CloudSubscriptionStatusResponse,
|
||||
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay would otherwise intercept clicks on the topbar.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy backend (team_workspaces_enabled: false).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute(subscriptionStatus))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
|
||||
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
|
||||
// now routes through `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function bootApp(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
|
||||
test('avatar popover renders tier badge and balance from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
|
||||
await expect(popover.getByText('12,660')).toBeVisible()
|
||||
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('free-tier dialog shows the renewal date from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
// Boots with team workspaces enabled (production shape); the facade routes a
|
||||
// personal workspace through the workspace `/api/billing/*` endpoints. With
|
||||
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
|
||||
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
|
||||
// overwrites window.__CONFIG__ from /api/features, so the flags must come
|
||||
// from the features mock, not an init script.)
|
||||
await mockCloudBoot(
|
||||
page,
|
||||
{
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: 'MONTHLY',
|
||||
// 10:00Z keeps the en-US calendar date stable across CI timezones.
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
},
|
||||
{ team_workspaces_enabled: true, subscription_required: true }
|
||||
)
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByTestId('subscribe-to-run-button').click()
|
||||
|
||||
// T5: the dialog must source the date from facade renewalDate — when this
|
||||
// line read the legacy store it silently vanished for team users.
|
||||
await expect(
|
||||
page.getByText('Your credits refresh on Feb 20, 2099.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const HOST_NODE_ID = toNodeId(2)
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
const BOOT_FEATURES = {
|
||||
onboarding_survey_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 {
|
||||
BillingStatusResponse,
|
||||
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'
|
||||
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// With team workspaces enabled, the facade routes a personal workspace through
|
||||
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
|
||||
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
|
||||
// visible (free tier would swap it for "Upgrade to add credits").
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
has_funds: true,
|
||||
cancel_at: FUTURE_DATE,
|
||||
renewal_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 = {
|
||||
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
|
||||
})
|
||||
)
|
||||
|
||||
// Flag-on (team workspaces enabled) routes a personal workspace through the
|
||||
// workspace billing endpoints, so the popover sources its data from here.
|
||||
await page.route('**/api/billing/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBillingStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/plans', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ plans: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
|
||||
*
|
||||
* The credits tile only lives inside the authenticated cloud app, which the
|
||||
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
|
||||
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
|
||||
* the cloud app initializes against fully stubbed data. With team workspaces
|
||||
* enabled the facade routes a personal workspace through the workspace
|
||||
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
|
||||
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
|
||||
* should then render its total / progress bar / monthly+additional breakdown /
|
||||
* add-credits.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
|
||||
// same response shape, so one body fulfills both endpoints.
|
||||
const balanceRoute = (balance: {
|
||||
amount: number
|
||||
monthly: number
|
||||
prepaid: number
|
||||
}) =>
|
||||
jsonRoute({
|
||||
amount_micros: balance.amount,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: balance.amount,
|
||||
cloud_credit_balance_micros: balance.monthly,
|
||||
prepaid_balance_micros: balance.prepaid
|
||||
})
|
||||
|
||||
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
|
||||
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
|
||||
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
has_funds: true
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// Frontend-origin boot endpoints (proxied to the backend in production).
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only), and the flag gates the Workspace settings panel.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
// Include the mock user so the multi-user select screen auto-selects it
|
||||
// (paired with the `Comfy.userId` localStorage seed below).
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Non-empty settings with a completed tutorial keep the cloud app from
|
||||
// booting as a new user, whose Workflow Templates dialog would otherwise
|
||||
// auto-open and intercept the Settings click behind its modal backdrop.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy billing (flag-off path, api.comfy.org/customers/*).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
|
||||
// Workspace billing (flag-on path) — a personal workspace now routes through
|
||||
// `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(mockBillingStatus))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function mockBalance(
|
||||
page: Page,
|
||||
balance: { amount: number; monthly: number; prepaid: number }
|
||||
) {
|
||||
await page.unroute('**/customers/balance')
|
||||
await page.unroute('**/api/billing/balance')
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
}
|
||||
|
||||
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
|
||||
async function openPlanAndCredits(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
// Open Settings ▸ Workspace.
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
return dialog.getByRole('main')
|
||||
}
|
||||
|
||||
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
test('renders the unified tile with breakdown and add-credits', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
|
||||
// 10,550 -> used 10,550).
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
// Monthly allowance fully spent; additional credits keep generation going.
|
||||
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
CREATOR,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN,
|
||||
VIEWER
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
|
||||
*
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
async function openMembersTab(page: Page): Promise<Locator> {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: Locator, email: string): Locator {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
function menuButton(row: Locator): Locator {
|
||||
return row.getByRole('button', { name: 'More Options' })
|
||||
}
|
||||
|
||||
// Reka submenus open on real pointer travel or keyboard; Playwright's
|
||||
// synthetic hover doesn't trigger the pointermove handler, so drive the
|
||||
// subtrigger with ArrowRight instead.
|
||||
async function openChangeRoleSubmenu(page: Page) {
|
||||
const trigger = page.getByRole('menuitem', { name: 'Change role' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('row menus respect creator and self guards', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
|
||||
// own row; the two plain members each expose a menu.
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JOHN.email))
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JANE.email))
|
||||
).toBeVisible()
|
||||
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
|
||||
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
|
||||
|
||||
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
|
||||
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Change role' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Remove member' }).click()
|
||||
await expect(page.getByText('Remove this member?')).toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting the current role is a no-op', async ({ page }) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
|
||||
// The current role is a checked radio item so assistive tech can announce
|
||||
// which role is active.
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Manage members, payment methods, and workspace settings')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toHaveCount(0)
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const emails = content.getByText(/@test\.comfy\.org/)
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JOHN.email,
|
||||
MEMBER_JANE.email
|
||||
])
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JANE.email,
|
||||
MEMBER_JOHN.email
|
||||
])
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'owner'
|
||||
}
|
||||
])
|
||||
|
||||
// The promoted owner keeps its row menu (still demotable).
|
||||
await expect(menuButton(janeRow)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demoting an owner returns them to member', async ({ page }) => {
|
||||
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup([
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
ownerJane,
|
||||
MEMBER_JOHN
|
||||
])
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Demote to member' }).click()
|
||||
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'member'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('failed role change keeps the dialog open with an error toast', async ({
|
||||
page
|
||||
}) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
// Override the member route so PATCH fails after boot succeeds.
|
||||
await page.route('**/api/workspace/members/**', (route) =>
|
||||
route.request().method() === 'PATCH'
|
||||
? route.fulfill({ status: 500, body: '{}' })
|
||||
: route.fallback()
|
||||
)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import {
|
||||
member,
|
||||
mockWorkspace,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* The `?pricing=` deep link opens the pricing table on app load, gated to the
|
||||
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
|
||||
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
|
||||
|
||||
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
|
||||
// chain must not throw before it: a missing settings subpath, prompt exec_info,
|
||||
// or queue status each abort that chain.
|
||||
async function mockGraphBootExtras(page: Page) {
|
||||
// Boot only reads these; fall back on any write so an unexpected POST/PUT
|
||||
// surfaces instead of being masked by a blanket 200.
|
||||
await page.route('**/api/settings/**', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({}))
|
||||
})
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
})
|
||||
await page.route('**/api/queue', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setupCloudApp(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await mockGraphBootExtras(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, ws, members)
|
||||
await bootCloud(page)
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
})
|
||||
|
||||
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=team`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'For Teams' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('opens for a team original owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
})
|
||||
|
||||
test('is a silent no-op for a team member', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
await expect(pricingHeading(page)).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -39,16 +41,22 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
function evaluateGraph() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const nodeIds = {
|
||||
switchCfg: toNodeId(120),
|
||||
ksampler85: toNodeId(85),
|
||||
ksampler86: toNodeId(86)
|
||||
}
|
||||
|
||||
return comfyPage.page.evaluate((nodeIds) => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
const switchCfg = subgraph.getNodeById(nodeIds.switchCfg)
|
||||
const ksampler85 = subgraph.getNodeById(nodeIds.ksampler85)
|
||||
const ksampler86 = subgraph.getNodeById(nodeIds.ksampler86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
@@ -74,7 +82,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
if (
|
||||
String(link.origin_id) === '120' &&
|
||||
String(link.target_id) === '85'
|
||||
)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
@@ -89,7 +100,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
// Poll graph state once, then assert all properties
|
||||
|
||||
@@ -4,6 +4,9 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const IMAGE_COMPARE_NODE_ID = toNodeId(1)
|
||||
|
||||
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -29,15 +32,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, value }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
@@ -450,11 +453,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
|
||||
const minWidth = 400
|
||||
const minHeight = 350
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graphNode = window.app!.graph.getNodeById(1)
|
||||
const size = await comfyPage.page.evaluate((nodeId) => {
|
||||
const graphNode = window.app!.graph.getNodeById(nodeId)
|
||||
if (!graphNode?.size) return null
|
||||
return { width: graphNode.size[0], height: graphNode.size[1] }
|
||||
})
|
||||
}, IMAGE_COMPARE_NODE_ID)
|
||||
expect(
|
||||
size,
|
||||
'ImageCompare node id 1 must exist in loaded workflow graph'
|
||||
@@ -600,15 +603,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, url }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -95,15 +96,15 @@ test.describe('Image Crop', () => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
{ nodeId: toNodeId(1), bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@ import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const getGizmoConfig = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const modelConfig = n?.properties?.['Model Config'] as
|
||||
| { gizmo?: { enabled: boolean; mode: string } }
|
||||
| undefined
|
||||
return modelConfig?.gizmo
|
||||
})
|
||||
}, toNodeId(1))
|
||||
|
||||
test.describe('Load3D Gizmo Controls', () => {
|
||||
test(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Load3D', () => {
|
||||
test(
|
||||
@@ -67,13 +68,13 @@ test.describe('Load3D', () => {
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const config = n?.properties?.['Scene Config'] as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
return config?.backgroundColor
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe('#cc3333')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -331,7 +332,8 @@ wstest(
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.images?.[0]?.filename,
|
||||
toNodeId(1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
|
||||
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
|
||||
@@ -32,12 +34,13 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: SerializedNodeId) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
|
||||
@@ -33,49 +33,11 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Node library opens via sidebar', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const sidebarContent = comfyPage.page.locator(
|
||||
'.comfy-vue-side-bar-container'
|
||||
)
|
||||
await expect(sidebarContent).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await expect(essentialsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking essentials tab shows essential node cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essential node cards have node names', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.essentialsTab.click()
|
||||
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
@@ -86,21 +48,18 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.allTab.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
|
||||
|
||||
await essentialsTab.click()
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
await tab.essentialsTab.click()
|
||||
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
|
||||
await allNodesTab.click()
|
||||
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -245,8 +246,10 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
.click()
|
||||
|
||||
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
|
||||
(nodeId) =>
|
||||
window.app!.graph!.getNodeById(nodeId)?.outputs[0]?.links
|
||||
?.length ?? 0,
|
||||
toNodeId(2)
|
||||
)
|
||||
expect(
|
||||
replacedNodeOutputLinkCount,
|
||||
|
||||
@@ -10,8 +10,20 @@ import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
expectNoMissingModelUi,
|
||||
loadPromotedMissingModelAndOpenErrorsTab,
|
||||
selectLegacyPromotedAssetModel,
|
||||
selectSectionAssetPromotedModel,
|
||||
selectVueAssetPromotedModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -20,6 +32,8 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
@@ -55,7 +69,25 @@ const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
|
||||
id: 'test-resolved-promoted-model',
|
||||
name: RESOLVED_PROMOTED_MODEL_NAME,
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: RESOLVED_PROMOTED_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
const promotedModelTest = createCloudAssetsFixture([
|
||||
RESOLVED_PROMOTED_MODEL_ASSET
|
||||
])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
@@ -353,13 +385,94 @@ test.describe(
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest.describe(
|
||||
'Errors tab - Cloud promoted subgraph missing models',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
promotedModelTest.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
promotedModelTest.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectVueAssetPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectSectionAssetPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectLegacyPromotedAssetModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_ASSET.id
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
} from '@e2e/fixtures/utils/objectInfo'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
@@ -86,50 +90,6 @@ interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
type ObjectInfoResponse = Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
>
|
||||
|
||||
function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
) {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
const [valuesOrType, options] = input
|
||||
const optionsObject =
|
||||
options && typeof options === 'object' && !Array.isArray(options)
|
||||
if (Array.isArray(valuesOrType)) {
|
||||
input[0] = values
|
||||
} else if (valuesOrType !== 'COMBO') {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
|
||||
}
|
||||
|
||||
if (optionsObject) {
|
||||
Object.assign(options, { options: values })
|
||||
} else if (!Array.isArray(valuesOrType)) {
|
||||
throw new Error(
|
||||
`Expected ${nodeType}.${inputName} to have options metadata`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -161,57 +121,10 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
async function routeSetupObjectInfo(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void
|
||||
) {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
customize?.(objectInfo)
|
||||
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await page.route('**/object_info', objectInfoRouteHandler)
|
||||
return async () =>
|
||||
await page.unroute('**/object_info', objectInfoRouteHandler)
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -225,13 +138,16 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -246,7 +162,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
|
||||
@@ -8,12 +8,46 @@ import {
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
appendComboInputOptions,
|
||||
routeObjectInfoFromSetupApi
|
||||
} from '@e2e/fixtures/utils/objectInfo'
|
||||
import {
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
expectMissingModelReferenceCount,
|
||||
expectNoMissingModelUi,
|
||||
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
|
||||
expectSingleMissingModelReference,
|
||||
getMissingModelLabel,
|
||||
loadPromotedMissingModelAndOpenErrorsTab,
|
||||
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
|
||||
selectSectionComboPromotedModel,
|
||||
selectVueComboPromotedModelByTitle,
|
||||
setLegacyPromotedComboModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
const promotedModelTest = test.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
@@ -169,7 +203,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -265,7 +301,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
@@ -381,92 +419,184 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
let missingModelGroup: Locator
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
await test.step('A: shared-definition active host reports the missing model', async () => {
|
||||
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
})
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
|
||||
const siblingHostNodeId =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
||||
if (siblingHostNodeId === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
const siblingHost =
|
||||
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
|
||||
await siblingHost.centerOnNode()
|
||||
await siblingHost.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
|
||||
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
|
||||
await openErrorsTab(comfyPage)
|
||||
await expectSingleMissingModelReference(
|
||||
missingModelGroup,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
await test.step('C: changing the active host promoted widget resolves the model', async () => {
|
||||
const activeHost = await comfyPage.nodeOps.getNodeRefById(
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
|
||||
)
|
||||
await activeHost.centerOnNode()
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('D: the missing model UI clears', async () => {
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
})
|
||||
|
||||
await test.step('E: two missing shared-definition hosts report two references', async () => {
|
||||
const siblingHostNodeId =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
||||
if (siblingHostNodeId === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host')
|
||||
}
|
||||
|
||||
missingModelGroup =
|
||||
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
{
|
||||
[siblingHostNodeId]: FAKE_MODEL_NAME,
|
||||
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
|
||||
FAKE_MODEL_NAME
|
||||
},
|
||||
FAKE_MODEL_NAME,
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('F: changing one missing host leaves the other missing reference', async () => {
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
await expectMissingModelReferenceCount(
|
||||
missingModelGroup,
|
||||
FAKE_MODEL_NAME,
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('G: changing the remaining missing host clears the model error', async () => {
|
||||
const siblingHostTitle =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
|
||||
if (siblingHostTitle === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host title')
|
||||
}
|
||||
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
siblingHostTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model from the Parameters tab clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectSectionComboPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing an OSS legacy promoted model clears a nested subgraph error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await setLegacyPromotedComboModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Refreshing a resolved promoted missing model clears the combo invalid state',
|
||||
{ tag: ['@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
|
||||
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
||||
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
||||
|
||||
const objectInfoRoute = /\/object_info$/
|
||||
try {
|
||||
await comfyPage.page.route(objectInfoRoute, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
comfyPage.page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
.click()
|
||||
@@ -478,11 +608,31 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.page.unroute(objectInfoRoute)
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Reloading a resolved nested promoted model ignores stale interior values',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
[
|
||||
{
|
||||
subgraphNodeIdToEnter: '4',
|
||||
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
|
||||
},
|
||||
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
|
||||
],
|
||||
RESOLVED_PROMOTED_MODEL_NAME,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,15 +4,16 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
type NodeSnapshot = { id: number } & Position
|
||||
type NodeSnapshot = { id: NodeId } & Position
|
||||
|
||||
async function getAllNodePositions(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeSnapshot[]> {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((n) => ({
|
||||
id: n.id as number,
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
@@ -21,7 +22,7 @@ async function getAllNodePositions(
|
||||
|
||||
async function getNodePosition(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
nodeId: NodeId
|
||||
): Promise<Position | undefined> {
|
||||
return comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
|
||||
|
||||
@@ -1095,6 +1095,33 @@ test.describe('Assets sidebar - drag and drop', () => {
|
||||
const fileComboWidget = await nodes[0].getWidget(0)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
|
||||
})
|
||||
|
||||
test('Loading as workflow reuses asset name', async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job',
|
||||
preview_output: {
|
||||
filename: `testimage.png`,
|
||||
type: 'temp',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
const path = comfyPage.assetPath('workflowInMedia/workflow.webp')
|
||||
await comfyPage.page.route('**/view?**', (route) => route.fulfill({ path }))
|
||||
|
||||
const { assetsTab } = comfyPage.menu
|
||||
await assetsTab.open()
|
||||
await assetsTab.waitForAssets()
|
||||
await expect(assetsTab.assetCards).toHaveCount(1)
|
||||
|
||||
const targetPosition = { x: 400, y: 100 }
|
||||
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
|
||||
|
||||
const getTabName = () => comfyPage.menu.topbar.getActiveTabName()
|
||||
await expect.poll(getTabName).toContain('testimage')
|
||||
})
|
||||
})
|
||||
|
||||
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
|
||||
@@ -10,20 +10,6 @@ test.describe('Node library sidebar V2', () => {
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test('Can switch between tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await tab.blueprintsTab.click()
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('All tab displays node tree with folders', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
@@ -123,8 +109,9 @@ test.describe('Node library sidebar V2', () => {
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
await tab.allTab.click()
|
||||
|
||||
await tab.expandFolder('Comfy Blueprints')
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
|
||||
|
||||
@@ -198,7 +199,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootNodeTitle = await comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.graph!.getNodeById(nodeId)?.title ?? null,
|
||||
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
|
||||
toNodeId(OUTER_SUBGRAPH_NODE_ID_IN_NESTED)
|
||||
)
|
||||
expect(rootNodeTitle).toBe(newName)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
interface SubgraphNodePosition {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
@@ -57,12 +58,12 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('5')
|
||||
const subgraphNode = graph.getNodeById(nodeId)
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
}, toNodeId(5))
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
|
||||
@@ -260,17 +261,18 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const localSubgraphNodeId = toNodeId(subgraphNodeId)
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.canvas.graph!.getNodeById(nodeId)!.progress,
|
||||
subgraphNodeId
|
||||
localSubgraphNodeId
|
||||
)
|
||||
)
|
||||
.toBe(0.5)
|
||||
@@ -287,7 +289,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
@@ -298,11 +300,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const localSubgraphNodeId = toNodeId(subgraphNodeId)
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
|
||||
@@ -471,11 +471,10 @@ test.describe(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
let initialWidgetCount = 0
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
@@ -39,9 +42,10 @@ async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
const localHostNodeId = toNodeId(hostNodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
const hostNode = graph.getNodeById(id)
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
@@ -80,7 +84,7 @@ async function getPrimitiveFanoutSnapshot(
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}, localHostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
@@ -103,19 +107,20 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const hostNodeId = toNodeId(hostSubgraphNodeId)
|
||||
const interiorNodeIds = widgets.map(([id]) => toNodeId(id))
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
const interiorNode = hostNode.subgraph.getNodeById(id)
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
[hostNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
@@ -570,8 +575,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.map((n) => String(n.id))
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
@@ -587,10 +591,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
return graph._nodes.map((n) => Number(n.id)).sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
@@ -633,18 +634,18 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
const SENTINEL_IDS = new Set(['-1', '-10', '-20'])
|
||||
const isSentinelNodeId = (id: number | string) =>
|
||||
SENTINEL_IDS.has(String(id))
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
id: NodeId,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
if (!g.getNodeById(id)) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
@@ -460,16 +461,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
|
||||
const subgraphNodeId = toNodeId(19)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((entry: { name: string }) =>
|
||||
entry.name.includes('seed')
|
||||
)
|
||||
return widget?.label || widget?.name || null
|
||||
})
|
||||
}, subgraphNodeId)
|
||||
)
|
||||
.toBe(RENAMED_LABEL)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
@@ -67,7 +67,7 @@ function slotLocator(
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const key = getSlotKey(String(nodeId), slotIndex, isInput)
|
||||
const key = getSlotKey(nodeId, slotIndex, isInput)
|
||||
return page.locator(`[data-slot-key="${key}"]`)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
@@ -359,6 +360,55 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
|
||||
})
|
||||
|
||||
test('dragging a node moves all selected items', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(samplerLocator)
|
||||
const loaderLocator = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const loader = new VueNodeFixture(loaderLocator)
|
||||
|
||||
await test.step('create graph with group and reroute', async () => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Checkpoint')
|
||||
const samplerOptions = { position: { x: 800, y: 200 } }
|
||||
await comfyPage.searchBoxV2.addNode('KSampler', samplerOptions)
|
||||
await ksampler.getSlot('model').dragTo(loader.getSlot('MODEL'))
|
||||
|
||||
await test.step('add reroute', async () => {
|
||||
const b1 = await ksampler.getSlot('model').boundingBox()
|
||||
const b2 = await loader.getSlot('MODEL').boundingBox()
|
||||
if (!b1 || !b2) throw new Error('Failed to get bounds')
|
||||
|
||||
const x = (b1.x + b2.x + (b1.width + b2.width) / 2) / 2
|
||||
const y = (b1.y + b2.y + (b1.height + b2.height) / 2) / 2
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.click(x, y)
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
const rerouteCount = () =>
|
||||
comfyPage.page.evaluate(() => graph!.reroutes.size)
|
||||
await expect.poll(rerouteCount).toBe(1)
|
||||
})
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Control+G')
|
||||
await comfyPage.keyboard.selectAll()
|
||||
})
|
||||
|
||||
const getReroutePos = () =>
|
||||
comfyPage.page.evaluate(() => [...graph!.reroutes.values()][0])
|
||||
const getGroupPos = () =>
|
||||
comfyPage.page.evaluate(() => graph!.groups[0].pos)
|
||||
const initialReroutePos = await getReroutePos()
|
||||
const initialGroupPos = await getGroupPos()
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await expect.poll(getReroutePos).not.toEqual(initialReroutePos)
|
||||
await expect.poll(getGroupPos).not.toEqual(initialGroupPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('tooltips', { tag: '@vue-nodes' }, async () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
|
||||
})
|
||||
|
||||
test('widget value tooltips', async ({ comfyPage }) => {
|
||||
const tooltip = comfyPage.page.locator('.p-tooltip-text')
|
||||
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
|
||||
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
|
||||
|
||||
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
|
||||
await expect(tooltip, 'displays for numbers').toContainText('15668')
|
||||
|
||||
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip, "doesn't display for prompts").not.toContainText(
|
||||
'purple galaxy bottle'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -59,12 +60,13 @@ async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const localLoadImageId = toNodeId(loadImageId)
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
@@ -147,7 +149,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
|
||||
@@ -2,14 +2,15 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test('Can display a slot mismatched from widget type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const emptyLatent = window.app!.graph.getNodeById(5)!
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const emptyLatent = window.app!.graph.getNodeById(nodeId)!
|
||||
emptyLatent.inputs[0].type = 'INT,FLOAT'
|
||||
})
|
||||
}, toNodeId(5))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const width = comfyPage.vueNodes
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
@@ -15,6 +16,7 @@ async function getCropValue(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
): Promise<CropValue> {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
|
||||
@@ -34,7 +36,7 @@ async function getCropValue(
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
async function setCropBounds(
|
||||
@@ -42,6 +44,7 @@ async function setCropBounds(
|
||||
nodeId: number,
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
await comfyPage.page.evaluate(
|
||||
({ id, b }) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
@@ -51,7 +54,7 @@ async function setCropBounds(
|
||||
w.callback?.(b)
|
||||
}
|
||||
},
|
||||
{ id: nodeId, b: bounds }
|
||||
{ id: localNodeId, b: bounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
@@ -17,7 +18,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
|
||||
toNodeId(10)
|
||||
)
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user