Compare commits

..

10 Commits

Author SHA1 Message Date
glary-bot
c2433d7b35 fix(affiliates-terms): bump effective date to May 16, 2026
Roll the effective date forward to today so the ship date and the
published Effective Date match. The previous May 15 value was set on
2026-05-14 to be 'tomorrow' relative to that day; shipping has slipped
into May 16, so the date is now stale by one day.

Verified: typecheck (90 files, 0 errors), 36/36 unit tests, format
check clean, oxlint clean, build clean (52 pages, /affiliates/terms
emits 'Effective Date: May 16, 2026', no /zh-CN/affiliates route).
2026-05-16 08:53:33 +00:00
glary-bot
d98cc41146 fix(routes): make affiliateTerms locale-invariant in getRoutes
Re-prefixing /affiliates/terms with /zh-CN/ would produce a dead link
now that the localized affiliate-terms route has been removed (no
zh-CN page is served from the legal-reviewed English-only document).

Today nothing in apps/website/src or apps/website/e2e calls
getRoutes(<non-en>).affiliateTerms, so this is not user-visible. The
change pre-empts a future regression: most likely from the sibling
affiliates landing PR (#12002), whose footer 'Read the affiliate
program terms' link could route through getRoutes(locale).affiliateTerms
and 404 in zh-CN.

Implementation:
- Add a LOCALE_INVARIANT_ROUTE_KEYS set in src/config/routes.ts and
  skip the /<locale>/ prefix for keys in that set. affiliateTerms is
  the only member today.
- Add a guard test in legalSections.test.ts asserting that both
  getRoutes('en').affiliateTerms and getRoutes('zh-CN').affiliateTerms
  resolve to '/affiliates/terms'. Test count: 35 → 36 passing.

Verification:
- 'pnpm --filter @comfyorg/website typecheck' — 0 errors (90 files)
- 'pnpm --filter @comfyorg/website test:unit' — 36/36 passing
- 'pnpm format:check apps/website' — clean
- 'pnpm exec oxlint apps/website/src apps/website/e2e --quiet' — 0 errors
- 'pnpm --filter @comfyorg/website build' — clean, 52 pages
- 'pnpm exec playwright test affiliates-terms' — 9/9 passing

Addresses the single low-severity finding from an Oracle code review of
the prior two commits on this branch.
2026-05-14 07:04:27 +00:00
glary-bot
0fe808ea10 fix(affiliates-terms): remove zh-CN route, English-only legal copy
The Affiliate Program Terms is a legal-reviewed document. Shipping
an unreviewed Chinese translation as the active terms (the previous
zh-CN page rendered translation keys whose 'zh-CN' values intentionally
mirrored 'en' verbatim) exposes us to liability from the translation
diverging from the legal-approved English source, particularly if a
future contributor 'fixes' the placeholder strings into real Chinese
without going through legal.

This change:

- Removes 'apps/website/src/pages/zh-CN/affiliates/terms.astro'.
- Removes the 9 'Disallow: /zh-CN/affiliates/terms' lines from robots.txt
  (one per UA stack); the route no longer exists.
- Simplifies '/affiliates/terms.astro' to be unconditionally English-only:
  drops the runtime locale switch and the 'Locale' import, hardcodes
  'locale=en' on the LegalContentSection.
- Updates the comment on the affiliate-terms i18n block in
  'translations.ts' from 'zh-CN mirrors en until legal approves a
  translation' (which implied a translation could later be approved
  edit-in-place) to an explicit 'ENGLISH ONLY — do NOT translate;
  if a translated terms page is ever needed, add a separate route
  only after legal signs off on that specific translation'. The
  'zh-CN' values themselves still mirror 'en' in source because the
  translations dictionary's type signature requires every entry to
  satisfy Record<Locale, string>; they are now unreachable through
  any route, sitemap, or robots rule.

Sitemap exclusion in astro.config.ts is unchanged — it generically
flatMaps NOINDEX_PATHNAMES over LOCALE_PREFIXES, so removing the
'/zh-CN/affiliates/terms' route automatically drops the (now-stale)
zh-CN entry from the excluded set.

Verification:
- 'pnpm --filter @comfyorg/website typecheck' — 0 errors (90 files, was 91)
- 'pnpm --filter @comfyorg/website test:unit' — 35/35 passing
  (including legalSections.test.ts, 5/5)
- 'pnpm format:check apps/website' — clean
- 'pnpm exec oxlint apps/website/src apps/website/e2e --quiet' — 0 errors
- 'pnpm knip' — clean (pre-existing unrelated warning)
- 'pnpm --filter @comfyorg/website build' — clean, 52 pages built,
  '/zh-CN/affiliates/' absent from dist output
- 'pnpm exec playwright test affiliates-terms' — 9/9 passing
- Probed running 'pnpm preview' server:
    /affiliates/terms        → 200
    /zh-CN/affiliates/terms  → 404 (standard site 404 page)
- Built sitemap-0.xml: zero affiliate-terms entries (en or zh-CN)
- Built robots.txt: 9 'Disallow: /affiliates/terms' entries, zero
  'Disallow: /zh-CN/...' entries

Manual verification:
Screenshots attached at .glary/screenshots/affiliate-terms-hero-date.png
(hero shows 'Effective Date: May 15, 2026' centered under H1) and
.glary/screenshots/affiliate-terms-zh-CN-404.png (zh-CN route resolves
to site 404).
2026-05-14 07:04:27 +00:00
glary-bot
178bb42271 fix(affiliates-terms): set effective date to May 15, 2026
Replaces the [TBD] placeholder in the affiliate-terms hero with a
real effective date. Updates both en and zh-CN entries (zh-CN
intentionally mirrors en per the existing convention noted at the
top of the affiliate-terms block in translations.ts).

The date renders in the centered caption directly under the page H1
via the existing 'affiliate-terms.effective-date' i18n key — no
component or layout changes needed.
2026-05-14 07:04:27 +00:00
Glary-Bot
893fb7429f fix(affiliates-terms): merge mobile TOC link colors via cn()
The mobile TOC anchor was setting text-primary-comfy-canvas in its
static class while the conditional :class added text-primary-comfy-
yellow or text-primary-warm-gray. Three text-* utilities targeting the
same property let CSS source order decide the winner instead of the
active-section state. Switch to cn() from @comfyorg/tailwind-utils so
tailwind-merge resolves the color conflict deterministically, matching
the codebase convention from AGENTS.md and ContentSection.vue.

The desktop TOC was already fine because its only static color rule
was hover:text-primary-comfy-canvas, which doesn't conflict with the
base text colors.
2026-05-05 21:00:38 +00:00
Glary-Bot
0f0bcc9de8 fix(affiliates-terms): preserve URL hash on TOC click for shareable permalinks
Because the TOC links use `@click.prevent` to drive smooth-scroll
manually, the browser's native hash update was suppressed and users
couldn't copy the URL after clicking a TOC entry to share a deep link.
`scrollToSection` now calls `history.replaceState` to sync the hash
without polluting back/forward history.

Adds an e2e regression test that asserts `window.location.hash` is
updated to the matching section anchor after a TOC click.
2026-05-05 20:50:23 +00:00
Glary-Bot
dcc81d9180 feat(affiliates-terms): apply Nav's four legal-copy edits
1. Section 7: change '12-month commission window' to '3-month
   commission window' so the termination clause matches Section 3's
   declared 3-month commission duration. Section 9's 'preceding 12
   months' liability cap is intentionally left unchanged.
2. Section 3: append a new 'Commission cessation' bullet covering
   subscription cancellation and refunded/charged-back transactions
   (cross-references Section 4).
3. Move the Effective Date from a footer at the end of the article to
   a centered caption directly under the H1, matching the legal-doc
   convention. Drops the now-unused effectiveDateKey/Label props from
   LegalContentSection so the component stays focused on body content.
4. Section 3: change the minimum payout threshold from $30 to $100.
2026-05-05 20:36:50 +00:00
Glary-Bot
5c215aa999 fix(affiliates-terms): address CodeRabbit review
- e2e: 'in order' test now actually verifies DOM ordering via
  compareDocumentPosition, not just element presence. The previous
  loop would have passed even with shuffled sections.
- robots.txt: also disallow /zh-CN/affiliates/terms in every
  user-agent block. The sitemap filter already excluded both locales
  but the robots rules only blocked /affiliates/terms, leaving the
  zh-CN draft crawlable.
- e2e: move the desktop TOC click/scroll test out of the @smoke
  describe block into a separate 'interactions' describe so smoke
  coverage stays render/visibility only, matching the convention used
  in apps/website/e2e/responsive.spec.ts.
2026-05-05 09:40:42 +00:00
Glary-Bot
8a1ce890d0 fix(affiliates-terms): address review feedback
- Drop the redundant 'Last updated' footer row that mirrored the
  Effective Date; the spec's last-updated stamp is satisfied by the
  single Effective Date line.
- Remove the now-unused 'lastUpdatedLabel' i18n key, prop, and
  associated test/e2e assertions.
- Add src/pages/zh-CN/affiliates/terms.astro so /zh-CN/affiliates/terms
  is generated alongside its en counterpart, matching the existing
  privacy-policy / terms-of-service localization pattern.
2026-05-05 03:55:43 +00:00
Glary-Bot
4453d858ed feat: draft Affiliate Program Terms page at /affiliates/terms
Adds a noindex'd legal page rendering the Comfy.org Affiliate Program
Terms and Conditions verbatim from i18n translations, so legal/non-
engineers can edit copy without a code change.

LegalContentSection.vue derives sections from i18n keys, renders
paragraph + bullet-list blocks, sticky desktop TOC, collapsed details
accordion TOC on mobile, smooth-scroll anchors with header offset,
IntersectionObserver-driven active state.

Anchor IDs match the spec (#1-program-overview through
#11-miscellaneous) for stable deep-linking.

BaseLayout noindex prop emits robots meta; robots.txt and the sitemap
filter both exclude /affiliates/terms (and the zh-CN locale variant).

Effective Date driven by a single i18n key
(affiliate-terms.effective-date), consumed by the page footer.

Unit tests assert the eleven canonical sections, numbered titles, and
that internal-only sections are not leaked. Playwright covers desktop
and mobile flows including accordion toggle, anchor scroll, and
viewport overflow.
2026-05-05 03:45:00 +00:00
413 changed files with 3229 additions and 36353 deletions

View File

@@ -54,14 +54,10 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
- name: Upload perf metrics
if: always()

View File

@@ -20,8 +20,6 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -39,33 +37,31 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -86,7 +82,7 @@ jobs:
done
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -95,7 +91,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -104,7 +100,6 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
@@ -119,7 +114,6 @@ jobs:
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -128,9 +122,7 @@ jobs:
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -1,88 +0,0 @@
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
#
# Runs on any PR touching extension-api declaration files, extension-api-v2
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
#
# Two jobs:
# test — vitest run against src/extension-api-v2/__tests__/
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
# blast_radius ≥ 2.0 category is missing a stub triple)
#
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
name: 'CI: Tests Extension API'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Extension API tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run extension-api test suite
run: pnpm test:extension-api
- name: Run with coverage (push only)
if: github.event_name == 'push'
run: pnpm test:extension-api:coverage
- name: Upload coverage to Codecov
if: github.event_name == 'push'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: extension-api
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
compat-floor:
name: Compat-floor gate (blast_radius ≥ 2.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.11'
- name: Install PyYAML
run: pip install pyyaml
- name: Check compat floor
run: python3 scripts/check-compat-floor.py
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.

View File

@@ -4,9 +4,6 @@ name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- 'core/**'
- 'cloud/**'
paths:
- 'apps/website/**'
- 'packages/design-system/**'

View File

@@ -1,97 +0,0 @@
# Description: Publish @comfyorg/extension-api to npm with provenance attestation.
#
# Triggered by a tag push matching 'extension-api-v*' (e.g. extension-api-v0.1.0).
# Also supports workflow_dispatch for a manual dry-run (set dry_run: true).
#
# Prerequisites (one-time human setup):
# - NPM_TOKEN secret must be set in the repo/org settings with publish
# access to the @comfyorg scope on npmjs.com.
# - The @comfyorg npm scope already exists (used by @comfyorg/comfyui-frontend).
#
# PKG4.D4 (MIG1 / Phase A — surface-only shim)
name: 'Extension API: Publish'
on:
push:
tags:
- 'extension-api-v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run — build and verify without publishing'
required: false
default: true
type: boolean
permissions:
contents: write # needed to create GitHub Release
id-token: write # needed for npm provenance via OIDC
jobs:
publish:
name: Publish @comfyorg/extension-api
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history for release notes
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Setup npm registry
uses: actions/setup-node@v6
with:
registry-url: 'https://registry.npmjs.org/'
- name: Build package
run: pnpm --filter @comfyorg/extension-api build
- name: Typecheck package
run: pnpm --filter @comfyorg/extension-api typecheck
- name: Verify package version matches tag
if: github.event_name == 'push'
run: |
TAG="${GITHUB_REF_NAME}" # e.g. extension-api-v0.1.0
PKG_VERSION=$(node -p "require('./packages/extension-api/package.json').version")
TAG_VERSION="${TAG#extension-api-v}" # strip prefix → 0.1.0
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
exit 1
fi
echo "Version check passed: $PKG_VERSION"
- name: Publish to npm (with provenance)
if: github.event_name == 'push' || !inputs.dry_run
run: |
cd packages/extension-api
npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Dry-run report
if: inputs.dry_run
run: |
echo "=== DRY RUN — would publish ==="
cd packages/extension-api
npm pack --dry-run
echo "=== End dry run ==="
- name: Create GitHub Release
if: github.event_name == 'push'
uses: actions/github-script@v8
with:
script: |
const tag = context.ref.replace('refs/tags/', '')
const { data: release } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: tag,
generate_release_notes: true,
draft: false,
prerelease: context.ref.includes('-alpha') || context.ref.includes('-beta') || context.ref.includes('-rc')
})
console.log(`Release created: ${release.html_url}`)

View File

@@ -1,65 +0,0 @@
# Description: Typecheck and build the @comfyorg/extension-api package.
# Runs on PRs and pushes touching the public type surface, the core .v2.ts
# implementations, or the package scaffold — so regressions in the published
# contract are caught before merge.
#
# PKG4.D3 (MIG1 / Phase A — surface-only shim)
name: 'Extension API: Typecheck'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extensions/core/*.v2.ts'
- 'src/services/extension-api-service.ts'
- 'packages/extension-api/**'
- '.github/workflows/extension-api-*.yml'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extensions/core/*.v2.ts'
- 'src/services/extension-api-service.ts'
- 'packages/extension-api/**'
- '.github/workflows/extension-api-*.yml'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
typecheck:
name: Build + typecheck @comfyorg/extension-api
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build package (emit declarations)
run: pnpm --filter @comfyorg/extension-api build
- name: Typecheck package
run: pnpm --filter @comfyorg/extension-api typecheck
- name: Smoke-test consumer (tsc --noEmit on minimal extension)
# Verifies the published types are consumable from an external module
# that imports from '@comfyorg/extension-api'. Uses a minimal fixture
# checked in to packages/extension-api/test/smoke/.
run: |
cd packages/extension-api
if [ -d test/smoke ]; then
pnpm exec tsc --noEmit --project test/smoke/tsconfig.json
else
echo "No smoke test found — skipping (add packages/extension-api/test/smoke/ to enable)"
fi

View File

@@ -6,7 +6,6 @@
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/extension-api/build/**",
"packages/registry-types/src/comfyRegistryTypes.ts",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",

View File

@@ -9,10 +9,12 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const NOINDEX_PATHNAMES = ['/affiliates/terms']
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
LOCALE_PREFIXES.flatMap((prefix) => [
...PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`),
...NOINDEX_PATHNAMES.map((path) => `${prefix}${path}`)
])
)
function isExcludedFromSitemap(page: string): boolean {

View File

@@ -0,0 +1,143 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates/terms'
const SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
test.describe('Affiliate Terms — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders heading and is marked noindex', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
).toBeVisible()
const robotsContent = await page
.locator('meta[name="robots"]')
.getAttribute('content')
expect(robotsContent).toContain('noindex')
})
test('exposes one anchor per legal section in order', async ({ page }) => {
for (const id of SECTION_IDS) {
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
}
const orderedIds = await page.evaluate(
(ids) => {
const elements = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
return elements
.slice()
.sort((a, b) => {
const relation = a.compareDocumentPosition(b)
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
.map((el) => el.id)
},
[...SECTION_IDS]
)
expect(orderedIds).toEqual([...SECTION_IDS])
})
test('renders an effective date footer', async ({ page }) => {
await expect(page.getByText(/Effective Date:/)).toBeVisible()
})
test('skips internal-only sections (competitive analysis, open questions)', async ({
page
}) => {
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
await expect(
page.getByText(/Open questions for legal review/i)
).toHaveCount(0)
})
})
test.describe('Affiliate Terms — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('clicking a desktop TOC link scrolls to the matching section', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await expect(desktopToc).toBeVisible()
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
await link.click()
const target = page.locator('[id="5-prohibited-activities"]')
await expect(target).toBeInViewport()
})
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
await expect
.poll(() => page.evaluate(() => window.location.hash))
.toBe('#7-termination')
})
})
test.describe('Affiliate Terms — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows a collapsed accordion TOC by default', async ({ page }) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await expect(accordion).toBeVisible()
await expect(accordion).not.toHaveAttribute('open', '')
})
test('expanding the accordion reveals every section link', async ({
page
}) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await accordion.locator('summary').click()
await expect(accordion).toHaveAttribute('open', '')
for (const id of SECTION_IDS) {
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
}
})
test('headings remain readable at narrow viewports without horizontal overflow', async ({
page
}) => {
const heading = page.getByRole('heading', { name: '1. Program Overview' })
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'heading box').not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
})
})

View File

@@ -32,34 +32,16 @@ test.describe('Careers page @smoke', () => {
}
})
test('clicking a department button scrolls to and activates that section', async ({
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})

View File

@@ -1,61 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

View File

@@ -1,33 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -1,71 +1,27 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -76,4 +32,13 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -61,7 +61,7 @@ test.describe('Payment failed page @smoke', () => {
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Unable to complete payment/i,
name: /Payment was not completed/i,
level: 1
})
).toBeVisible()
@@ -102,7 +102,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
await expect(page).toHaveTitle('支付失败 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })
page.getByRole('heading', { name: '支付未完成', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {
@@ -126,7 +126,6 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/enterprise',
'/cloud/pricing',
'/contact',
'/download',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -27,7 +27,6 @@
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 100 }
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -30,29 +30,38 @@ Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
Disallow: /affiliates/terms
User-agent: GPTBot
Allow: /
Disallow: /affiliates/terms
User-agent: OAI-SearchBot
Allow: /
Disallow: /affiliates/terms
User-agent: ChatGPT-User
Allow: /
Disallow: /affiliates/terms
User-agent: ClaudeBot
Allow: /
Disallow: /affiliates/terms
User-agent: Claude-User
Allow: /
Disallow: /affiliates/terms
User-agent: Claude-SearchBot
Allow: /
Disallow: /affiliates/terms
User-agent: PerplexityBot
Allow: /
Disallow: /affiliates/terms
User-agent: Google-Extended
Allow: /
Disallow: /affiliates/terms
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { t } from '../../i18n/translations'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() =>
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
const activeCategory = ref('')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0
if (isScrolling) return
const sections = sectionRefs.value as HTMLElement[]
if (sections.length === 0) return
let active = sections[0]
for (const el of sections) {
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
active = el
} else {
break
}
}
activeCategory.value = active.id.replace(/^careers-dept-/, '')
}
function scheduleUpdate() {
if (pendingFrame !== 0) return
pendingFrame = requestAnimationFrame(pickActiveSection)
}
onMounted(pickActiveSection)
useEventListener('scroll', scheduleUpdate, { passive: true })
useEventListener('resize', scheduleUpdate, { passive: true })
function scrollToDepartment(deptKey: string) {
activeCategory.value = deptKey
isScrolling = true
const el = document.getElementById(deptElementId(deptKey))
if (!el) {
isScrolling = false
return
}
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
pickActiveSection()
}
})
}
</script>
<template>
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
:model-value="activeCategory"
class="mt-4"
@update:model-value="scrollToDepartment"
/>
</div>
</div>
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
</p>
<div
v-for="dept in visibleDepartments"
:id="deptElementId(dept.key)"
:ref="sectionRefs.set"
v-for="dept in filteredDepartments"
:key="dept.key"
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
class="mb-12 last:mb-0"
>
<SectionLabel>
{{ dept.name }}

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const photos = [
{
src: 'https://media.comfy.org/website/careers/team0.webp',
@@ -15,34 +17,45 @@ const photos = [
{
src: 'https://media.comfy.org/website/careers/team3.webp',
alt: 'Team on a boat'
},
{
src: 'https://media.comfy.org/website/careers/team4.webp',
alt: 'Teammates posing at a restaurant'
},
{
src: 'https://media.comfy.org/website/careers/team5.webp',
alt: 'Teammates at a social gathering'
},
{
src: 'https://media.comfy.org/website/careers/team6.webp',
alt: 'Team sailing at golden hour'
},
{
src: 'https://media.comfy.org/website/careers/team7.webp',
alt: 'Team on a sailboat at sunset'
}
]
const loopedPhotos = [...photos, ...photos, ...photos]
const scrollRef = ref<HTMLElement>()
function onScroll() {
const el = scrollRef.value
if (!el) return
const third = el.scrollWidth / 3
const maxScroll = el.scrollWidth - el.clientWidth
if (el.scrollLeft >= maxScroll - 1) {
el.scrollLeft -= third
} else if (el.scrollLeft <= 1) {
el.scrollLeft += third
}
}
onMounted(() => {
const el = scrollRef.value
if (el) {
el.scrollLeft = el.scrollWidth / 3
}
})
</script>
<template>
<section class="py-12 md:py-24">
<div
ref="scrollRef"
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
style="scrollbar-width: none"
@scroll="onScroll"
>
<div
v-for="(photo, i) in photos"
v-for="(photo, i) in loopedPhotos"
:key="i"
class="aspect-3/4 h-64 shrink-0 md:h-96"
>

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
useEventListener,
useIntersectionObserver,
useTemplateRefsList
} from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
@@ -74,39 +58,22 @@ useIntersectionObserver(
{ rootMargin: '-20% 0px -60% 0px' }
)
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = sections[sections.length - 1]?.id
if (lastId) activeSection.value = lastId
}
onMounted(activateLastIfAtBottom)
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock
onComplete: () => {
isScrolling = false
}
})
return
}
clearScrollLock()
isScrolling = false
}
</script>

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-1 lg:flex"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-6 shrink-0"
class="bg-primary-comfy-yellow block size-7"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -14,28 +14,23 @@ const logos = [
'Ubisoft'
]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div class="animate-marquee flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
<div class="flex w-max gap-8">
<div class="animate-marquee-reverse flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,7 +5,6 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -23,10 +22,6 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -42,10 +37,7 @@ function handleLogoLoad() {
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>

View File

@@ -8,12 +8,10 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -26,8 +24,7 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
>
<div
v-if="!loaded"

View File

@@ -1,31 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
<div class="relative flex-1">
<video
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
autoplay
loop
muted
playsinline
class="w-full"
/>
</div>

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
},
{
label: t('useCase.advertising', locale),

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { scrollTo } from '../../scripts/smoothScroll'
const {
prefix,
locale = 'en',
tocLabelKey
} = defineProps<{
prefix: string
locale?: Locale
tocLabelKey: TranslationKey
}>()
interface Block {
type: 'paragraph' | 'list'
key: TranslationKey
}
interface LegalSection {
id: string
title: string
blocks: Block[]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildSections(): LegalSection[] {
const labelRegex = new RegExp(`^${escapeRegex(prefix)}\\.([^.]+)\\.label$`)
const sectionIds: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !sectionIds.includes(match[1])) sectionIds.push(match[1])
}
return sectionIds.map((id) => {
const blockRegex = new RegExp(
`^${escapeRegex(prefix)}\\.${escapeRegex(id)}\\.block\\.(\\d+)$`
)
const indices: number[] = []
for (const key of translationKeys) {
const match = key.match(blockRegex)
if (match) indices.push(parseInt(match[1]))
}
indices.sort((a, b) => a - b)
const blocks: Block[] = indices.map((i) => {
const key = `${prefix}.${id}.block.${i}` as TranslationKey
const value = t(key, locale)
return { type: value.includes('\n') ? 'list' : 'paragraph', key }
})
const titleKey = `${prefix}.${id}.title` as TranslationKey
return {
id,
title: hasKey(titleKey)
? t(titleKey, locale)
: t(`${prefix}.${id}.label` as TranslationKey, locale),
blocks
}
})
}
const sections = buildSections()
const tocItems = computed(() =>
sections.map((s) => ({ id: s.id, title: s.title }))
)
const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
const mobileTocOpen = ref(false)
let isScrolling = false
const HEADER_OFFSET = -144
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
best = entry
}
if (best) activeSection.value = best.target.id
},
{ rootMargin: '-20% 0px -60% 0px' }
)
function scrollToSection(id: string) {
activeSection.value = id
isScrolling = true
mobileTocOpen.value = false
const nextHash = `#${id}`
if (window.location.hash !== nextHash) {
history.replaceState(null, '', nextHash)
}
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
}
})
return
}
isScrolling = false
}
function listItems(key: TranslationKey): string[] {
return t(key, locale).split('\n')
}
</script>
<template>
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-12 lg:pb-32">
<div class="mx-auto max-w-7xl lg:flex lg:gap-16">
<aside class="lg:w-64 lg:shrink-0">
<details
:open="mobileTocOpen"
class="border-transparency-white-t4 mb-8 rounded-2xl border bg-(--site-bg-soft) lg:hidden"
@toggle="
(e) => (mobileTocOpen = (e.target as HTMLDetailsElement).open)
"
>
<summary
class="text-primary-comfy-canvas flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-semibold tracking-wide select-none"
>
<span>{{ t(tocLabelKey, locale) }}</span>
<span
:class="
mobileTocOpen
? 'rotate-180 transition-transform'
: 'transition-transform'
"
aria-hidden="true"
>
</span>
</summary>
<ul class="border-transparency-white-t4 border-t p-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
:class="
cn(
'hover:bg-transparency-white-t4 block rounded-lg px-3 py-2 text-sm transition-colors',
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
)
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</details>
<nav
class="hidden lg:sticky lg:top-32 lg:block"
:aria-label="t(tocLabelKey, locale)"
>
<p
class="text-primary-warm-gray mb-4 text-xs font-semibold tracking-widest uppercase"
>
{{ t(tocLabelKey, locale) }}
</p>
<ul class="space-y-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
class="hover:text-primary-comfy-canvas block text-sm/snug transition-colors"
:class="
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</nav>
</aside>
<article class="flex-1 lg:max-w-3xl">
<section
v-for="section in sections"
:id="section.id"
:ref="sectionRefs.set"
:key="section.id"
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
>
<h2
class="text-primary-comfy-canvas mb-6 text-2xl font-light lg:text-3xl"
>
{{ section.title }}
</h2>
<template v-for="block in section.blocks" :key="block.key">
<p
v-if="block.type === 'paragraph'"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed lg:text-base/relaxed"
v-html="t(block.key, locale)"
/>
<ul
v-else
class="mt-4 space-y-2 pl-5 text-sm/relaxed lg:text-base/relaxed"
>
<li
v-for="(item, j) in listItems(block.key)"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-2"
>
<span
class="bg-primary-comfy-yellow mt-2 size-1.5 shrink-0 rounded-full"
aria-hidden="true"
/>
<span v-html="item" />
</li>
</ul>
</template>
</section>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, t, translationKeys } from '../../i18n/translations'
const PREFIX = 'affiliate-terms'
const EXPECTED_SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
function deriveAffiliateSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('affiliate terms i18n', () => {
it('exposes the eleven canonical sections in numeric order', () => {
const ids = deriveAffiliateSectionIds()
expect(ids).toEqual([...EXPECTED_SECTION_IDS])
})
it('every section has a label, title, and at least one block', () => {
for (const id of EXPECTED_SECTION_IDS) {
expect(hasKey(`${PREFIX}.${id}.label`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('section titles follow the "N. Section Name" pattern', () => {
for (const id of EXPECTED_SECTION_IDS) {
const title = t(`${PREFIX}.${id}.title` as never)
const numberPrefix = id.split('-')[0]
expect(title).toMatch(new RegExp(`^${numberPrefix}\\. `))
}
})
it('exposes the effective date and page-chrome keys editors will need', () => {
expect(hasKey('affiliate-terms.effective-date')).toBe(true)
expect(hasKey('affiliate-terms.page.title')).toBe(true)
expect(hasKey('affiliate-terms.page.heading')).toBe(true)
expect(hasKey('affiliate-terms.page.tocLabel')).toBe(true)
expect(hasKey('affiliate-terms.page.effectiveDateLabel')).toBe(true)
})
it('does not include any internal-only "Competitive analysis" or "Open questions" keys', () => {
const internalRegex = /(competitive-analysis|open-questions|legal-review)/
const leaks = translationKeys.filter(
(key) => key.startsWith(PREFIX) && internalRegex.test(key)
)
expect(leaks).toEqual([])
})
it('exposes affiliate terms at the canonical /affiliates/terms path regardless of locale', () => {
// Guards against re-introducing /zh-CN/affiliates/terms, which would
// serve an unreviewed translation of legal-reviewed copy. See the
// comment on LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
expect(getRoutes('en').affiliateTerms).toBe('/affiliates/terms')
expect(getRoutes('zh-CN').affiliateTerms).toBe('/affiliates/terms')
})
})

View File

@@ -77,10 +77,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
features: [{ text: 'pricing.plan.creator.feature1' }],
isPopular: true
},
{
@@ -93,10 +90,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
features: [{ text: 'pricing.plan.pro.feature1' }]
},
{
id: 'enterprise',

View File

@@ -35,20 +35,20 @@ onMounted(() => {
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden pt-16 lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pt-[min(8vw,10rem)] lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (overlaps text slightly; stacks above on mobile, right on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:translate-y-[40px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
class="block size-full overflow-visible"
class="block size-full"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<g>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
@@ -84,7 +84,7 @@ onMounted(() => {
/>
<!-- Exploding block cluster -->
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
@@ -353,7 +353,7 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroBlockClip">
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>

View File

@@ -276,6 +276,29 @@ onUnmounted(() => {
fill="#211927"
/>
</g>
<!-- Left-edge fade -->
<rect
x="300"
y="150"
width="250"
height="900"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"
y2="600"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
</defs>
</svg>
</div>

View File

@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>

View File

@@ -1,328 +0,0 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import * as THREE from 'three'
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
import { prefersReducedMotion } from './useReducedMotion'
const IMAGE_COUNT = 16
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
interface HeroLogoConfig {
speed: number
tiltX: number
tiltZ: number
zoom: number
fov: number
logoColor: string
extrudeDepth: number
cursorTiltStrength: number
bgScale: number
slideDuration: number
}
const DEFAULTS: HeroLogoConfig = {
speed: 1,
tiltX: -0.1,
tiltZ: -0.1,
zoom: 7,
fov: 50,
logoColor: '#F2FF59',
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4
}
function buildImageUrls(): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${BASE_URL}/image_sequence_${index}.webp`
})
}
function parseShapes(): THREE.Shape[] {
const loader = new SVGLoader()
const svgData = loader.parse(SVG_MARKUP)
const shapes: THREE.Shape[] = []
svgData.paths.forEach((path) => {
shapes.push(...SVGLoader.createShapes(path))
})
return shapes
}
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
return Promise.all(
urls.map(
(url) =>
new Promise<THREE.Texture | null>((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const tex = new THREE.Texture(img)
tex.needsUpdate = true
tex.colorSpace = THREE.SRGBColorSpace
resolve(tex)
}
img.onerror = () => resolve(null)
img.src = url
})
)
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
}
export function useHeroLogo(
containerRef: Ref<HTMLElement | undefined>,
config: Partial<HeroLogoConfig> = {}
) {
const cfg = { ...DEFAULTS, ...config }
const loaded = ref(false)
let cleanup: (() => void) | undefined
onMounted(async () => {
try {
const container = containerRef.value
if (!container || prefersReducedMotion()) return
const { width, height } = container.getBoundingClientRect()
const renderer = new THREE.WebGLRenderer({
antialias: true,
stencil: true,
alpha: true
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.style.position = 'absolute'
renderer.domElement.style.inset = '0'
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
let disposed = false
const teardowns: Array<() => void> = []
cleanup = () => {
disposed = true
teardowns.forEach((fn) => fn())
}
teardowns.push(() => {
renderer.dispose()
renderer.domElement.remove()
})
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
cfg.fov,
width / height,
0.1,
1000
)
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes()
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox!
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)
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls()
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) => {
if (!disposed) textures.push(...rest)
})
// Background plane (stencil read)
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
const bgPlaneMat = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 1,
map: textures[0] ?? null,
depthTest: false,
depthWrite: false,
stencilWrite: true,
stencilFunc: THREE.EqualStencilFunc,
stencilRef: 1,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
stencilZPass: THREE.KeepStencilOp
})
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
bgPlane.renderOrder = 1
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
scene.add(bgPlane)
// Logo group
const group = new THREE.Group()
scene.add(group)
const s = scaleFactor
const depth = cfg.extrudeDepth
// Front face
const shapeGeo = new THREE.ShapeGeometry(shapes)
shapeGeo.translate(-cx, -cy, 0)
shapeGeo.scale(s, -s, s)
const shapeMat = new THREE.MeshBasicMaterial({
color: cfg.logoColor,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
transparent: true
})
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
logoMesh.renderOrder = 2
group.add(logoMesh)
// Extrusion stencil mask
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
depth,
bevelEnabled: false
})
extrudeGeo.translate(-cx, -cy, -depth)
extrudeGeo.scale(s, -s, s)
const extrudeMat = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
stencilWrite: true,
stencilRef: 1,
stencilFunc: THREE.AlwaysStencilFunc,
stencilZPass: THREE.ReplaceStencilOp,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
side: THREE.DoubleSide
})
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
extrudeMesh.renderOrder = 0
group.add(extrudeMesh)
// Interaction
let isDragging = false
let previousX = 0
let dragVelocity = 0
let currentTiltX = 0
let currentTiltY = 0
let pointerX = 0
let pointerY = 0
let rotationT = 0
let currentSlide = 0
let slideTimer = 0
let animationId = 0
function onMouseMove(e: MouseEvent) {
pointerX = (e.clientX / window.innerWidth) * 2 - 1
pointerY = (e.clientY / window.innerHeight) * 2 - 1
}
function onPointerDown(e: PointerEvent) {
isDragging = true
dragVelocity = 0
previousX = e.clientX
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return
dragVelocity = (e.clientX - previousX) * 0.005
rotationT += dragVelocity
previousX = e.clientX
}
function onPointerUp() {
isDragging = false
}
function onResize() {
const rect = container!.getBoundingClientRect()
camera.aspect = rect.width / rect.height
camera.updateProjectionMatrix()
renderer.setSize(rect.width, rect.height)
}
window.addEventListener('mousemove', onMouseMove)
renderer.domElement.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('resize', onResize)
const clock = new THREE.Clock()
function animate() {
if (disposed) return
animationId = requestAnimationFrame(animate)
const dt = clock.getDelta()
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
dragVelocity *= 0.95
rotationT += dragVelocity
} else if (!isDragging) {
dragVelocity = 0
}
rotationT += cfg.speed * dt
currentTiltX += (pointerY - currentTiltX) * 0.08
currentTiltY += (pointerX - currentTiltY) * 0.08
group.rotation.y = rotationT % (Math.PI * 2)
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
group.rotation.z = cfg.tiltZ
if (textures.length > 1) {
slideTimer += dt
if (slideTimer >= cfg.slideDuration) {
slideTimer = 0
currentSlide = (currentSlide + 1) % textures.length
bgPlaneMat.map = textures[currentSlide]
bgPlaneMat.needsUpdate = true
}
}
renderer.render(scene, camera)
}
animate()
teardowns.push(
() => cancelAnimationFrame(animationId),
() => window.removeEventListener('mousemove', onMouseMove),
() =>
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
() => window.removeEventListener('pointermove', onPointerMove),
() => window.removeEventListener('pointerup', onPointerUp),
() => window.removeEventListener('resize', onResize),
() => bgPlaneGeo.dispose(),
() => bgPlaneMat.dispose(),
() => shapeGeo.dispose(),
() => shapeMat.dispose(),
() => extrudeGeo.dispose(),
() => extrudeMat.dispose(),
() => textures.forEach((tex) => tex.dispose())
)
} catch (err) {
console.error('[useHeroLogo] initialization failed:', err)
cleanup?.()
}
})
onUnmounted(() => {
cleanup?.()
})
return { loaded }
}

View File

@@ -15,14 +15,6 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
/**
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
* landscape screencast). Sizes the embed container to match so rounded
* corners hug the content instead of empty letterbox space. Source from
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
* invert it). Defaults to 16/9 if omitted.
*/
readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -40,8 +32,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
},
{
slug: 'workflow-templates',
@@ -57,25 +48,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'community-workflows',
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
category: 'demos.category.gettingStarted',
title: 'demos.community-workflows.title',
description: 'demos.community-workflows.description',
transcript: 'demos.community-workflows.transcript',
ogImage: '/images/demos/community-workflows-og.png',
thumbnail: '/images/demos/community-workflows-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'community', 'workflow', 'hub'],
publishedDate: '2026-05-04',
modifiedDate: '2026-05-04',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
}
]

View File

@@ -14,16 +14,30 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliateTerms: '/affiliates/terms',
contact: '/contact'
} as const
type Routes = typeof baseRoutes
// Routes that are served only at their canonical path regardless of the
// active locale. Localized variants of these routes intentionally do not
// exist, so getRoutes(<non-en>) must not prefix them — emitting
// /zh-CN/<route> would produce a dead link.
//
// affiliateTerms: legal-reviewed English-only document. See the comment
// header in src/pages/affiliates/terms.astro and the affiliate-terms i18n
// block in src/i18n/translations.ts for the reasoning.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>(['affiliateTerms'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`])
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}

View File

@@ -1119,10 +1119,6 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1147,10 +1143,6 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
@@ -1607,7 +1599,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
@@ -2178,6 +2170,189 @@ const translations = {
'如果您对本条款有任何疑问,请通过 <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a> 与我们联系。'
},
// ── Affiliate Program Terms ───────────────────────────────────────
// Legal-reviewed copy — ENGLISH ONLY. There is no /zh-CN/affiliates/terms
// route, and the `'zh-CN'` values below intentionally duplicate `en`
// verbatim only to satisfy the translations dictionary's required
// Record<Locale, string> shape. Do NOT translate these into Chinese:
// shipping an unreviewed translation as the active terms exposes us to
// liability from the translation diverging from the legal-approved
// English source. If a translated terms page is ever needed, add a
// separate `/affiliates/terms/<locale>` route only after legal signs
// off on that specific translation as the authoritative version.
'affiliate-terms.effective-date': {
en: 'May 16, 2026',
'zh-CN': 'May 16, 2026'
},
'affiliate-terms.1-program-overview.label': {
en: 'PROGRAM',
'zh-CN': 'PROGRAM'
},
'affiliate-terms.1-program-overview.title': {
en: '1. Program Overview',
'zh-CN': '1. Program Overview'
},
'affiliate-terms.1-program-overview.block.0': {
en: 'The <a href="https://comfy.org" class="text-white underline">Comfy.org</a> Affiliate Program ("<strong>Program</strong>") allows approved participants ("<strong>Affiliates</strong>") to earn commissions by referring new paying customers to Comfy Cloud. By participating in this program, you agree to these terms.',
'zh-CN':
'The <a href="https://comfy.org" class="text-white underline">Comfy.org</a> Affiliate Program ("<strong>Program</strong>") allows approved participants ("<strong>Affiliates</strong>") to earn commissions by referring new paying customers to Comfy Cloud. By participating in this program, you agree to these terms.'
},
'affiliate-terms.2-eligible-products.label': {
en: 'PRODUCTS',
'zh-CN': 'PRODUCTS'
},
'affiliate-terms.2-eligible-products.title': {
en: '2. Eligible Products',
'zh-CN': '2. Eligible Products'
},
'affiliate-terms.2-eligible-products.block.0': {
en: 'Commissions are earned on Comfy Cloud paid subscription plans only. The following are excluded from commission eligibility: free tier signups (unless they later convert to paid), one-time credit purchases, enterprise contracts negotiated directly with Comfy sales, and API-only usage billed outside of standard subscription plans.',
'zh-CN':
'Commissions are earned on Comfy Cloud paid subscription plans only. The following are excluded from commission eligibility: free tier signups (unless they later convert to paid), one-time credit purchases, enterprise contracts negotiated directly with Comfy sales, and API-only usage billed outside of standard subscription plans.'
},
'affiliate-terms.3-commission-structure.label': {
en: 'COMMISSION',
'zh-CN': 'COMMISSION'
},
'affiliate-terms.3-commission-structure.title': {
en: '3. Commission Structure',
'zh-CN': '3. Commission Structure'
},
'affiliate-terms.3-commission-structure.block.0': {
en: 'Commission rate: 30% recurring on the net subscription amount received by Comfy.org\nCommission duration: 3 months from the referred customer\u2019s first paid subscription\nCookie/attribution window: 60 days from the referral click\nMinimum payout threshold: $100\nPayout schedule: Monthly, within the first 10 business days of each month after the receipt of applicable payment by Comfy from its referred customer\nPayout method: Via the affiliate tracking platform (Stripe Express or PayPal)\nCommission cessation: To the extent a referred customer\u2019s subscription is canceled, in whole or in part, the affiliate shall correspondingly cease to receive commission payments, even within the 3-month commission window. Refunded or charged-back transactions are not eligible for commission, and any commission previously paid for such transactions will be deducted from future payouts (see Section 4).',
'zh-CN':
'Commission rate: 30% recurring on the net subscription amount received by Comfy.org\nCommission duration: 3 months from the referred customer\u2019s first paid subscription\nCookie/attribution window: 60 days from the referral click\nMinimum payout threshold: $100\nPayout schedule: Monthly, within the first 10 business days of each month after the receipt of applicable payment by Comfy from its referred customer\nPayout method: Via the affiliate tracking platform (Stripe Express or PayPal)\nCommission cessation: To the extent a referred customer\u2019s subscription is canceled, in whole or in part, the affiliate shall correspondingly cease to receive commission payments, even within the 3-month commission window. Refunded or charged-back transactions are not eligible for commission, and any commission previously paid for such transactions will be deducted from future payouts (see Section 4).'
},
'affiliate-terms.4-attribution-rules.label': {
en: 'ATTRIBUTION',
'zh-CN': 'ATTRIBUTION'
},
'affiliate-terms.4-attribution-rules.title': {
en: '4. Attribution Rules',
'zh-CN': '4. Attribution Rules'
},
'affiliate-terms.4-attribution-rules.block.0': {
en: 'Commissions are attributed on a last-click basis within the 60-day cookie window\nIf a referred customer cancels and re-subscribes within 60 days, the original affiliate retains attribution\nIf a referred customer upgrades their plan, commission is calculated on the upgraded amount\nIf a referred customer downgrades their plan, commission adjusts to the new plan amount\nRefunded transactions are not eligible for commission\nAny commission paid on refunded transactions will be deducted from future payouts to you',
'zh-CN':
'Commissions are attributed on a last-click basis within the 60-day cookie window\nIf a referred customer cancels and re-subscribes within 60 days, the original affiliate retains attribution\nIf a referred customer upgrades their plan, commission is calculated on the upgraded amount\nIf a referred customer downgrades their plan, commission adjusts to the new plan amount\nRefunded transactions are not eligible for commission\nAny commission paid on refunded transactions will be deducted from future payouts to you'
},
'affiliate-terms.5-prohibited-activities.label': {
en: 'PROHIBITED',
'zh-CN': 'PROHIBITED'
},
'affiliate-terms.5-prohibited-activities.title': {
en: '5. Prohibited Activities',
'zh-CN': '5. Prohibited Activities'
},
'affiliate-terms.5-prohibited-activities.block.0': {
en: 'Affiliates must NOT:',
'zh-CN': 'Affiliates must NOT:'
},
'affiliate-terms.5-prohibited-activities.block.1': {
en: '<strong>Self-refer</strong>: Use your own affiliate link to purchase or receive discounts on your own account\n<strong>Bid on branded keywords</strong>: Run paid search campaigns (Google Ads, Bing Ads, etc.) targeting "ComfyUI," "Comfy.org," "Comfy Cloud," or any misspellings or variations thereof\n<strong>Misrepresent</strong>: Impersonate Comfy.org, claim to be an employee, or create assets that could be confused with official Comfy.org materials\n<strong>Spam</strong>: Send unsolicited bulk emails, messages, or engage in any form of spam promotion\n<strong>Cookie stuff</strong>: Use hidden iframes, pop-unders, or any technical means to set cookies without genuine user intent\n<strong>Incentivize clicks</strong>: Offer monetary rewards, points, or other incentives solely for clicking your affiliate link (content recommendations are fine)\n<strong>Use misleading claims</strong>: Make false or exaggerated claims about Comfy.org products, pricing, or features\n<strong>Promote on prohibited content</strong>: Place affiliate links on sites containing illegal content, hate speech, or adult content\n<strong>Contrary to laws</strong>: Place affiliate links in any market that is prohibited as a region under the laws of the United States of America.',
'zh-CN':
'<strong>Self-refer</strong>: Use your own affiliate link to purchase or receive discounts on your own account\n<strong>Bid on branded keywords</strong>: Run paid search campaigns (Google Ads, Bing Ads, etc.) targeting "ComfyUI," "Comfy.org," "Comfy Cloud," or any misspellings or variations thereof\n<strong>Misrepresent</strong>: Impersonate Comfy.org, claim to be an employee, or create assets that could be confused with official Comfy.org materials\n<strong>Spam</strong>: Send unsolicited bulk emails, messages, or engage in any form of spam promotion\n<strong>Cookie stuff</strong>: Use hidden iframes, pop-unders, or any technical means to set cookies without genuine user intent\n<strong>Incentivize clicks</strong>: Offer monetary rewards, points, or other incentives solely for clicking your affiliate link (content recommendations are fine)\n<strong>Use misleading claims</strong>: Make false or exaggerated claims about Comfy.org products, pricing, or features\n<strong>Promote on prohibited content</strong>: Place affiliate links on sites containing illegal content, hate speech, or adult content\n<strong>Contrary to laws</strong>: Place affiliate links in any market that is prohibited as a region under the laws of the United States of America.'
},
'affiliate-terms.6-content-guidelines.label': {
en: 'CONTENT & IP',
'zh-CN': 'CONTENT & IP'
},
'affiliate-terms.6-content-guidelines.title': {
en: '6. Content Guidelines and Intellectual Property Rights',
'zh-CN': '6. Content Guidelines and Intellectual Property Rights'
},
'affiliate-terms.6-content-guidelines.block.0': {
en: 'Affiliates must clearly disclose the affiliate relationship in accordance with FTC guidelines (US) and equivalent regulations in their jurisdiction\nRecommended disclosure: "This page contains affiliate links. I may earn a commission if you sign up through my link."\nAffiliates may use Comfy.org logos and brand assets only as provided in the official affiliate asset kit, and may not modify them\nComfy.org retains all rights, including in any of its intellectual property apart from the limited use rights granted herein',
'zh-CN':
'Affiliates must clearly disclose the affiliate relationship in accordance with FTC guidelines (US) and equivalent regulations in their jurisdiction\nRecommended disclosure: "This page contains affiliate links. I may earn a commission if you sign up through my link."\nAffiliates may use Comfy.org logos and brand assets only as provided in the official affiliate asset kit, and may not modify them\nComfy.org retains all rights, including in any of its intellectual property apart from the limited use rights granted herein'
},
'affiliate-terms.7-termination.label': {
en: 'TERMINATION',
'zh-CN': 'TERMINATION'
},
'affiliate-terms.7-termination.title': {
en: '7. Termination',
'zh-CN': '7. Termination'
},
'affiliate-terms.7-termination.block.0': {
en: 'Either party may terminate the affiliate relationship at any time with 14 days\u2019 prior written notice\nComfy.org reserves the right to immediately terminate and withhold commissions if an affiliate violates any of the prohibited activities listed above\nUpon termination, any unpaid commissions above the minimum threshold will be paid in the next regular payout cycle\nCommissions on referred customers will cease at the time of termination, even if within the 3-month commission window',
'zh-CN':
'Either party may terminate the affiliate relationship at any time with 14 days\u2019 prior written notice\nComfy.org reserves the right to immediately terminate and withhold commissions if an affiliate violates any of the prohibited activities listed above\nUpon termination, any unpaid commissions above the minimum threshold will be paid in the next regular payout cycle\nCommissions on referred customers will cease at the time of termination, even if within the 3-month commission window'
},
'affiliate-terms.8-program-modifications.label': {
en: 'MODIFICATIONS',
'zh-CN': 'MODIFICATIONS'
},
'affiliate-terms.8-program-modifications.title': {
en: '8. Program Modifications',
'zh-CN': '8. Program Modifications'
},
'affiliate-terms.8-program-modifications.block.0': {
en: 'Comfy.org reserves the right to modify these terms, commission rates, or program structure with 30 days notice to active affiliates\nContinued participation after notice constitutes acceptance of modified terms',
'zh-CN':
'Comfy.org reserves the right to modify these terms, commission rates, or program structure with 30 days notice to active affiliates\nContinued participation after notice constitutes acceptance of modified terms'
},
'affiliate-terms.9-indemnification.label': {
en: 'LIABILITY',
'zh-CN': 'LIABILITY'
},
'affiliate-terms.9-indemnification.title': {
en: '9. Indemnification and Limitation of Liability',
'zh-CN': '9. Indemnification and Limitation of Liability'
},
'affiliate-terms.9-indemnification.block.0': {
en: 'You will indemnify Comfy.org from any third party claim arising out of your breach of these terms.\nComfy.org\u2019s liability to any affiliate shall not (i) exceed the total commissions paid to that affiliate in the preceding 12 months, and (ii) include any indirect, consequential, punitive or any other type of special damages.\nComfy.org is not responsible for tracking failures caused by user browser settings, ad blockers, or VPNs, though we employ server-side tracking to minimize these issues',
'zh-CN':
'You will indemnify Comfy.org from any third party claim arising out of your breach of these terms.\nComfy.org\u2019s liability to any affiliate shall not (i) exceed the total commissions paid to that affiliate in the preceding 12 months, and (ii) include any indirect, consequential, punitive or any other type of special damages.\nComfy.org is not responsible for tracking failures caused by user browser settings, ad blockers, or VPNs, though we employ server-side tracking to minimize these issues'
},
'affiliate-terms.10-governing-law.label': {
en: 'GOVERNING LAW',
'zh-CN': 'GOVERNING LAW'
},
'affiliate-terms.10-governing-law.title': {
en: '10. Governing Law',
'zh-CN': '10. Governing Law'
},
'affiliate-terms.10-governing-law.block.0': {
en: 'These terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to conflict of law principles. All disputes arising under this Agreement shall be resolved exclusively in the state or federal courts in the State of Delaware.',
'zh-CN':
'These terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to conflict of law principles. All disputes arising under this Agreement shall be resolved exclusively in the state or federal courts in the State of Delaware.'
},
'affiliate-terms.11-miscellaneous.label': {
en: 'MISCELLANEOUS',
'zh-CN': 'MISCELLANEOUS'
},
'affiliate-terms.11-miscellaneous.title': {
en: '11. Miscellaneous',
'zh-CN': '11. Miscellaneous'
},
'affiliate-terms.11-miscellaneous.block.0': {
en: '<strong>(a) Entire Agreement.</strong> These terms constitutes the sole and entire agreement (including the attached schedules and exhibits) of the Parties with respect to the subject matter of these terms, and supersedes all prior and contemporaneous understandings, agreements, representations, and warranties, both written and oral, with respect to the subject matter. <strong>(b) Relationship of Parties.</strong> Each party is an independent contractor with regard to these terms. Nothing contained in These terms shall be construed as creating any agency, partnership, joint venture, or other form of joint enterprise, employment, or fiduciary relationship between the Parties. Neither party, by virtue of these terms, will have any right, power, or authority to act or create an obligation, express or implied, on behalf of the other party. <strong>(c) Assignment.</strong> Neither party shall assign any of its rights or delegate any of its obligations hereunder without the prior written consent of the other party, which consent shall not be unreasonably withheld, conditioned or delayed. <strong>(d) Severability.</strong> If any term or provision of these terms is invalid, illegal, or unenforceable in any jurisdiction, such invalidity, illegality, or unenforceability shall not affect any other term or provision of these terms or invalidate or render unenforceable such term or provision in any other jurisdiction. Upon a determination that any term or provision is invalid, illegal or unenforceable, the Parties hereto shall negotiate in good faith to modify these terms to effect the original intent of the Parties as closely as possible in order that the transactions contemplated hereby be consummated as originally contemplated to the greatest extent possible. <strong>(e) Waiver.</strong> No waiver by either party of any of the provisions hereof shall be effective unless explicitly set forth in writing and signed by the party so waiving. <strong>(f) Notice.</strong> Each party shall deliver all notices, requests, consents, claims, demands, waivers, and other communications under these terms in writing to the email utilized for the primary contact for the other party. <strong>(g) Cumulative Remedies.</strong> All rights and remedies provided in these terms are cumulative and not exclusive, and the exercise by a party of any right or remedy does not preclude the exercise of any other rights or remedies that may now or subsequently be available at Law, in equity, by statute, in any other agreement between the Parties or otherwise. <strong>(h) No Third-Party Beneficiaries.</strong> These terms benefits solely the Parties to these terms and their respective permitted successors and assigns and nothing in these terms, express or implied, confers on any other person or entity any legal or equitable right, benefit, or remedy of any nature whatsoever under or by reason of these terms.',
'zh-CN':
'<strong>(a) Entire Agreement.</strong> These terms constitutes the sole and entire agreement (including the attached schedules and exhibits) of the Parties with respect to the subject matter of these terms, and supersedes all prior and contemporaneous understandings, agreements, representations, and warranties, both written and oral, with respect to the subject matter. <strong>(b) Relationship of Parties.</strong> Each party is an independent contractor with regard to these terms. Nothing contained in These terms shall be construed as creating any agency, partnership, joint venture, or other form of joint enterprise, employment, or fiduciary relationship between the Parties. Neither party, by virtue of these terms, will have any right, power, or authority to act or create an obligation, express or implied, on behalf of the other party. <strong>(c) Assignment.</strong> Neither party shall assign any of its rights or delegate any of its obligations hereunder without the prior written consent of the other party, which consent shall not be unreasonably withheld, conditioned or delayed. <strong>(d) Severability.</strong> If any term or provision of these terms is invalid, illegal, or unenforceable in any jurisdiction, such invalidity, illegality, or unenforceability shall not affect any other term or provision of these terms or invalidate or render unenforceable such term or provision in any other jurisdiction. Upon a determination that any term or provision is invalid, illegal or unenforceable, the Parties hereto shall negotiate in good faith to modify these terms to effect the original intent of the Parties as closely as possible in order that the transactions contemplated hereby be consummated as originally contemplated to the greatest extent possible. <strong>(e) Waiver.</strong> No waiver by either party of any of the provisions hereof shall be effective unless explicitly set forth in writing and signed by the party so waiving. <strong>(f) Notice.</strong> Each party shall deliver all notices, requests, consents, claims, demands, waivers, and other communications under these terms in writing to the email utilized for the primary contact for the other party. <strong>(g) Cumulative Remedies.</strong> All rights and remedies provided in these terms are cumulative and not exclusive, and the exercise by a party of any right or remedy does not preclude the exercise of any other rights or remedies that may now or subsequently be available at Law, in equity, by statute, in any other agreement between the Parties or otherwise. <strong>(h) No Third-Party Beneficiaries.</strong> These terms benefits solely the Parties to these terms and their respective permitted successors and assigns and nothing in these terms, express or implied, confers on any other person or entity any legal or equitable right, benefit, or remedy of any nature whatsoever under or by reason of these terms.'
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms — Comfy',
'zh-CN': 'Affiliate Terms — Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
'zh-CN': 'Comfy.org Affiliate Program Terms and Conditions.'
},
'affiliate-terms.page.heading': {
en: 'Affiliate Terms',
'zh-CN': 'Affiliate Terms'
},
'affiliate-terms.page.tocLabel': {
en: 'On this page',
'zh-CN': '本页内容'
},
'affiliate-terms.page.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': '生效日期'
},
// Customers page
'customers.hero.label': {
en: 'CUSTOMER STORIES',
@@ -3506,6 +3681,18 @@ const translations = {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
'customers.detail.groove-jones.topic-10.block.2.label': {
en: 'GROOVE JONES CONTRIBUTORS',
'zh-CN': 'GROOVE JONES 贡献者'
},
'customers.detail.groove-jones.topic-10.block.2.name': {
en: 'TBD',
'zh-CN': '待补充'
},
'customers.detail.groove-jones.topic-10.block.2.role': {
en: 'TBD',
'zh-CN': '待补充'
},
// Contact FormSection
'contact.form.badge': {
@@ -3570,20 +3757,6 @@ const translations = {
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
},
'demos.community-workflows.title': {
en: 'Explore and Use a Community Workflow from the Hub',
'zh-CN': '探索并使用社区工作流'
},
'demos.community-workflows.description': {
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
},
'demos.community-workflows.transcript': {
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
'zh-CN':
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
},
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
@@ -3704,8 +3877,8 @@ const translations = {
'zh-CN': '支付'
},
'payment.failed.title': {
en: 'Unable to complete payment',
'zh-CN': '无法完成支付'
en: 'Payment was not completed',
'zh-CN': '支付未完成'
},
'payment.failed.subtitle': {
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",

View File

@@ -10,7 +10,6 @@ import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
title: string
description?: string
keywords?: string[]
ogImage?: string
noindex?: boolean
}
@@ -18,13 +17,10 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
@@ -66,7 +62,6 @@ const websiteJsonLd = {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>

View File

@@ -0,0 +1,31 @@
---
// Affiliate Program Terms — English only, by design.
// Legal-reviewed copy must not be served under a localized route until
// legal explicitly approves a translation; rendering an unreviewed
// translation as the active terms exposes us to liability from the
// translation diverging from the approved English source. See the
// matching comment in src/i18n/translations.ts for the i18n block.
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/legal/HeroSection.vue'
import LegalContentSection from '../../components/legal/LegalContentSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('affiliate-terms.page.title')}
description={t('affiliate-terms.page.description')}
noindex
>
<HeroSection title={t('affiliate-terms.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('affiliate-terms.page.effectiveDateLabel')}: {
t('affiliate-terms.effective-date')
}
</p>
<LegalContentSection
prefix="affiliate-terms"
locale="en"
tocLabelKey="affiliate-terms.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -7,14 +7,9 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
import PricingSection from '../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../components/product/cloud/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud — AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>
<BaseLayout title="Comfy Cloud — AI in the Cloud">
<HeroSection />
<ReasonSection />
<AIModelsSection />

View File

@@ -121,7 +121,6 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
client:load
/>

View File

@@ -7,14 +7,9 @@ import ReasonSection from '../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Download Comfy — Run AI Locally"
description={t('download.hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
>
<BaseLayout title="Download Comfy — Run AI Locally">
<CloudBannerSection />
<HeroSection client:load />
<ReasonSection />

View File

@@ -8,14 +8,9 @@ import UseCaseSection from '../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../components/home/GetStartedSection.vue'
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t('hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
>
<BaseLayout title="Comfy — Professional Control of Visual AI">
<HeroSection client:load />
<SocialProofBarSection />
<ProductShowcaseSection client:load />

View File

@@ -7,14 +7,9 @@ import AudienceSection from '../../../components/product/cloud/AudienceSection.v
import PricingSection from '../../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../../components/product/cloud/FAQSection.vue'
import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud — 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>
<BaseLayout title="Comfy Cloud — 云端 AI">
<HeroSection locale="zh-CN" />
<ReasonSection locale="zh-CN" />
<AIModelsSection locale="zh-CN" />

View File

@@ -122,7 +122,6 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
locale="zh-CN"
client:load
/>

View File

@@ -7,14 +7,9 @@ import ReasonSection from '../../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title="下载 — Comfy"
description={t('download.hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<BaseLayout title="下载 — Comfy">
<CloudBannerSection locale="zh-CN" />
<HeroSection locale="zh-CN" client:load />
<ReasonSection locale="zh-CN" />

View File

@@ -8,14 +8,9 @@ import UseCaseSection from '../../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -101,13 +101,13 @@
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
transform: translateX(-50%);
}
}
@keyframes marquee-reverse {
0% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
transform: translateX(-50%);
}
100% {
transform: translateX(0);
@@ -115,15 +115,11 @@
}
@utility animate-marquee {
@media (prefers-reduced-motion: no-preference) {
animation: marquee 30s linear infinite;
}
animation: marquee 30s linear infinite;
}
@utility animate-marquee-reverse {
@media (prefers-reduced-motion: no-preference) {
animation: marquee-reverse 30s linear infinite;
}
animation: marquee-reverse 30s linear infinite;
}
@keyframes ripple-effect {

View File

@@ -1,232 +0,0 @@
{
"id": "14af6003-d4ee-4dee-8e3d-cbff2e5519b3",
"revision": 0,
"last_node_id": 205,
"last_link_id": 383,
"nodes": [
{
"id": 205,
"type": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
"pos": [4720, 5820],
"size": [400, 470],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"label": "lotus_model",
"name": "unet_name_1",
"type": "COMBO",
"widget": {
"name": "unet_name_1"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"proxyWidgets": [["76", "unet_name"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 205,
"lastLinkId": 383,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Depth to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [28, 4936, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [1599, 4936, 128, 68]
},
"inputs": [
{
"id": "80e6915f-5d59-4d6b-a197-d8c565ad2922",
"name": "unet_name_1",
"type": "COMBO",
"linkIds": [258],
"pos": [132, 4960]
}
],
"outputs": [
{
"id": "47f9a22d-6619-4917-9447-a7d5d08dceb5",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [],
"pos": [1623, 4960]
}
],
"widgets": [],
"nodes": [
{
"id": 76,
"type": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
"pos": [250, 4910],
"size": [400, 210],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 258
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"proxyWidgets": [["203", "unet_name"]]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 258,
"origin_id": -10,
"origin_slot": 0,
"target_id": 76,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [-30, -4760]
}
}
},
{
"id": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 205,
"lastLinkId": 383,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Image to Depth Map (Lotus)",
"inputNode": {
"id": -10,
"bounding": [-60, -173, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [1650, -173, 128, 68]
},
"inputs": [
{
"id": "d721b249-fd2a-441b-9a78-2805f04e2644",
"name": "unet_name",
"type": "COMBO",
"linkIds": [256],
"pos": [44, -149]
}
],
"outputs": [
{
"id": "2ec278bd-0b66-4b30-9c5b-994d5f638214",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [],
"pos": [1674, -149]
}
],
"widgets": [],
"nodes": [
{
"id": 203,
"type": "UNETLoader",
"pos": [180, -200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 256
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": []
}
],
"properties": {},
"widgets_values": ["lotus-depth-d-v1-1.safetensors", "default"]
}
],
"groups": [],
"links": [
{
"id": 256,
"origin_id": -10,
"origin_slot": 0,
"target_id": 203,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [40, 350]
}
}
}
]
},
"config": {},
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [-4500, -5670]
}
},
"version": 0.4
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,179 +0,0 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [225, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [270, 108],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [270, 104],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [225, 104],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -1,42 +0,0 @@
{
"last_node_id": 10,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -190,9 +190,6 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -355,12 +352,6 @@ export class ComfyPage {
await nextFrame(this.page)
}
async idleFrames(count: number) {
for (let i = 0; i < count; i++) {
await this.nextFrame()
}
}
async delay(ms: number) {
return sleep(ms)
}
@@ -469,15 +460,10 @@ export const testComfySnapToGridGridSize = 50
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
}>({
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
return use(page)
@@ -494,7 +480,7 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
comfyPage: async ({ page, request }, use, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -503,7 +489,6 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({
@@ -539,10 +524,6 @@ export const comfyPageFixture = base.extend<{
await comfyPage.cloudAuth.mockAuth()
}
if (Object.keys(initialFeatureFlags).length > 0) {
await comfyPage.featureFlags.seedFlags(initialFeatureFlags)
}
await comfyPage.setup()
if (isVueNodes) {

View File

@@ -5,7 +5,6 @@ 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'
export class VueNodeHelpers {
/**
@@ -38,22 +37,6 @@ export class VueNodeHelpers {
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
}
getInputSlotRow(nodeId: string, slotIndex: number): Locator {
return this.getNodeLocator(nodeId)
.locator('.lg-slot--input')
.filter({
has: this.page.locator(
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
)
})
}
getInputSlotConnectionDot(nodeId: string, slotIndex: number): Locator {
return this.getInputSlotRow(nodeId, slotIndex).getByTestId(
TestIds.node.slotConnectionDot
)
}
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.
@@ -217,20 +200,13 @@ export class VueNodeHelpers {
}
}
/**
* Locator for the Enter Subgraph footer button.
*/
getSubgraphEnterButton(nodeId?: string): Locator {
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const editButton = this.getSubgraphEnterButton(nodeId)
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -1,34 +1,8 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
const ASSETS_ROUTE_PATTERN = /\/api\/assets(?:\?.*)?$/
const cloudAssetRequestsByPage = new WeakMap<Page, string[]>()
function makeAssetsResponse(assets: ReadonlyArray<Asset>): ListAssetsResponse {
return { assets: [...assets], total: assets.length, has_more: false }
}
export function assetRequestIncludesTag(url: string, tag: string): boolean {
const includeTags = new URL(url).searchParams.get('include_tags') ?? ''
return includeTags
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.includes(tag)
}
export function countAssetRequestsByTag(
requests: string[],
tag: string
): number {
return requests.filter((url) => assetRequestIncludesTag(url, tag)).length
}
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
@@ -40,31 +14,3 @@ export const assetApiFixture = base.extend<{
await assetApi.clearMocks()
}
})
export function createCloudAssetsFixture(assets: ReadonlyArray<Asset>) {
return comfyPageFixture.extend<{
cloudAssetRequests: string[]
}>({
page: async ({ page }, use) => {
const cloudAssetRequests: string[] = []
cloudAssetRequestsByPage.set(page, cloudAssetRequests)
async function assetsRouteHandler(route: Route) {
cloudAssetRequests.push(route.request().url())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(assets))
})
}
await page.route(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
await use(page)
await page.unroute(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
cloudAssetRequestsByPage.delete(page)
},
cloudAssetRequests: async ({ page }, use) => {
await use(cloudAssetRequestsByPage.get(page) ?? [])
}
})
}

View File

@@ -39,32 +39,10 @@ class ComfyQueueButton {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
}
public async openOptions() {
const options = new ComfyQueueButtonOptions(this.actionbar.page)
if (!(await options.menu.isVisible())) {
await this.dropdownButton.click()
}
return options
}
}
class ComfyQueueButtonOptions {
public readonly menu: Locator
public readonly modeItems: Locator
constructor(public readonly page: Page) {
this.menu = page.getByRole('menu')
this.modeItems = this.menu.getByRole('menuitem')
}
public modeItem(name: string) {
return this.menu.getByRole('menuitem', { name, exact: true })
}
public async selectMode(name: string) {
await this.modeItem(name).click()
}
constructor(public readonly page: Page) {}
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {

View File

@@ -20,7 +20,6 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -1,23 +1,16 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
export class Topbar {
private readonly menuLocator: Locator
private readonly menuTrigger: Locator
readonly newWorkflowButton: Locator
readonly workflowTabs: Locator
readonly integratedTabBarActions: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
this.integratedTabBarActions = this.workflowTabs.getByTestId(
TestIds.topbar.integratedTabBarActions
)
}
async getTabNames(): Promise<string[]> {
@@ -82,7 +75,7 @@ export class Topbar {
}
getSaveDialog(): Locator {
return this.page.getByRole('dialog').getByRole('textbox')
return this.page.locator('.p-dialog-content input')
}
saveWorkflow(workflowName: string): Promise<void> {
@@ -116,9 +109,9 @@ export class Topbar {
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}

View File

@@ -1,12 +0,0 @@
import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
constructor(public readonly root: Locator) {
this.selection = root.locator('button span span')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,15 +9,13 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -62,16 +60,13 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
/** The main content area where outputs are displayed*/
public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.mobile = new MobileAppHelper(comfyPage)
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -130,7 +125,6 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

@@ -215,12 +215,11 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
let filtered = this.getFilteredAssets(includeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -297,29 +296,15 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
private getFilteredAssets(tags: string[]): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

View File

@@ -127,7 +127,9 @@ export class BuilderSelectHelper {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })

View File

@@ -1,5 +1,4 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -14,7 +13,6 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -24,14 +22,13 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const evaluateParams: {
dropPosition: Position
@@ -42,22 +39,12 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName || filePath) {
const resolvedPath = filePath ?? assetPath(fileName!)
const displayName = fileName ?? basename(resolvedPath)
let buffer: Buffer
try {
buffer = readFileSync(resolvedPath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
{ cause: error }
)
}
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -161,13 +148,6 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropFilePath(
filePath: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ filePath, ...options })
}
async dragAndDropURL(
url: string,
options: {

View File

@@ -1,10 +1,6 @@
import type { WebSocketRoute } from '@playwright/test'
import type {
NodeError,
NodeProgressState,
PromptResponse
} from '@/schemas/apiSchema'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
@@ -234,16 +230,6 @@ export class ExecutionHelper {
)
}
/** Send `progress_state` WS event with per-node execution state. */
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
this.requireWs().send(
JSON.stringify({
type: 'progress_state',
data: { prompt_id: jobId, nodes }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.

View File

@@ -1,176 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -1,33 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class MobileAppHelper {
private readonly page: Page
readonly contentPanel: Locator
readonly navigation: Locator
readonly navigationTabs: Locator
readonly view: Locator
readonly workflows: Locator
constructor(comfyPage: ComfyPage) {
this.page = comfyPage.page
this.view = this.page.getByTestId(TestIds.linear.mobile)
this.contentPanel = this.page.getByRole('tabpanel')
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
this.navigationTabs = this.navigation.getByRole('tab')
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

@@ -18,7 +18,9 @@ export class NodeOperationsHelper {
public readonly promptDialogInput: Locator
constructor(private comfyPage: ComfyPage) {
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
this.promptDialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
}
private get page() {

View File

@@ -362,9 +362,6 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
if (this.comfyPage.isVueNodes) {
await this.comfyPage.vueNodes.waitForNodes()
}
}
async countGraphPseudoPreviewEntries(): Promise<number> {

View File

@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -91,8 +91,6 @@ export const TestIds = {
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
@@ -116,8 +114,7 @@ export const TestIds = {
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
mainImage: 'main-image'
},
selectionToolbox: {
root: 'selection-toolbox',
@@ -144,14 +141,6 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
@@ -222,7 +211,6 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',
notificationBanner: 'queue-notification-banner'

View File

@@ -1,52 +0,0 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -7,9 +7,6 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.mp3')) return 'audio/mpeg'
if (name.endsWith('.flac')) return 'audio/flac'
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'

View File

@@ -1,7 +1,3 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -13,8 +13,6 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -25,8 +23,6 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
}
async getTitle(): Promise<string> {
@@ -43,16 +39,6 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -1,154 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
//an app without an image input will load the workflow
await test.step('App without an image input loads workflow', async () => {
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeHidden()
})
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
await expect(centerPanel).toBeVisible()
await expect(imageInput.root).toBeVisible()
})
await test.step('Dragging an image redirects to image input', async () => {
const initialImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropExternalResource({
fileName: 'workflow.webp',
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
await expect(imageInput.selection).not.toHaveText(initialImage)
await expect(
centerPanel,
'A file with workflow should not open a new workflow'
).toBeVisible()
})
await test.step('Dragging a url redirects to image input', async () => {
const secondImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({
filename: 'og-image.png',
type: 'input'
})
await expect(imageInput.selection).not.toHaveText(secondImage)
})
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['3', 'seed'],
['3', 'sampler_name'],
['6', 'text']
])
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
exact: true
})
const { input, incrementButton, decrementButton } =
comfyPage.vueNodes.getInputNumberControls(seed)
const initialValue = Number(await input.inputValue())
await seed.dragTo(incrementButton, { steps: 5 })
const intermediateValue = Number(await input.inputValue())
expect(intermediateValue).toBeGreaterThan(initialValue)
await seed.dragTo(decrementButton, { steps: 5 })
const endValue = Number(await input.inputValue())
expect(endValue).toBeLessThan(intermediateValue)
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
exact: true
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
const initialValue = Number(await steps.inputValue())
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue(String(initialValue + 5))
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue(String(initialValue + 2))
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -1,121 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.inputItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
//Manually set error state on checkpoint loader
//Shouldn't be needed on ci, but has spotty reliability
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget(
'Load Checkpoint',
'ckpt_name'
)
await expect(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is initially editable'
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await ksampler.header.dblclick({ force: true })
await expect(
ksampler.titleEditor.input,
'Double clicking node titles will not initiate a rename'
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
})
})

View File

@@ -133,29 +133,6 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets filters by exclude_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

View File

@@ -1,20 +1,51 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
const test = createCloudAssetsFixture(CLOUD_ASSETS)
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
}
await page.route(pattern, assetsRouteHandler)
await use()
await page.unroute(pattern, assetsRouteHandler)
},
{ auto: true }
]
})
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -31,9 +62,11 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
)
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
)
.toBe(true)

View File

@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear
const saveDialog = comfyPage.page.getByRole('dialog')
await expect(saveDialog).toBeVisible()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')

View File

@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page
.getByRole('dialog')
.filter({ hasText: 'Overwrite' })
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {

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

View File

@@ -1,548 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
async function searchKeybindings(page: Page, query: string) {
await getKeybindingSearchInput(page).fill(query)
}
async function clearSearch(page: Page) {
await getKeybindingSearchInput(page).clear()
}
function getKeybindingSearchInput(page: Page): Locator {
return page.getByPlaceholder('Search Keybindings...')
}
function getCommandRow(page: Page, commandId: string): Locator {
return page
.locator('.keybinding-panel tr')
.filter({ has: page.locator(`[title="${commandId}"]`) })
}
function getExpansionContent(page: Page, commandId: string): Locator {
// PrimeVue renders the expansion row as the next sibling <tr> of the
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
return getCommandRow(page, commandId)
.locator('xpath=following-sibling::tr[1]')
.getByTestId('keybinding-expansion-content')
}
async function openContextMenu(page: Page, commandId: string) {
const row = getCommandRow(page, commandId)
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
await expect(
page.getByRole('menuitem', { name: /Change keybinding/i })
).toBeVisible()
}
function getKeybindingInput(page: Page): Locator {
return getEditKeybindingDialog(page).locator('input[autofocus]')
}
function getEditKeybindingDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Modify keybinding/i })
}
function getRemoveAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Remove all keybindings/i })
}
function getResetAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Reset all keybindings/i })
}
async function pressComboOnInput(page: Page, combo: string) {
const input = getKeybindingInput(page)
await expect(input).toBeFocused()
await input.press(combo)
}
async function saveAndCloseKeybindingDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Save/i }).click()
await expect(dialog).toBeHidden()
}
async function cancelAndCloseDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Cancel/i }).click()
await expect(dialog).toBeHidden()
}
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
await row.getByRole('button', { name: /Add new keybinding/i }).click()
await pressComboOnInput(page, combo)
await saveAndCloseKeybindingDialog(page)
}
test.beforeEach(async ({ comfyPage }) => {
await registerNoBindingCommand(comfyPage)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
})
async function registerNoBindingCommand(comfyPage: ComfyPage) {
await comfyPage.page.evaluate((commandId) => {
const app = window.app!
app.registerExtension({
name: 'TestExtension.KeybindingPanelE2E',
commands: [{ id: commandId, function: () => {} }]
})
}, NO_BINDING_COMMAND)
}
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
test.describe('Row Expansion', () => {
test('Click on row with 2+ keybindings toggles expansion', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
await expect(expansionContent).toBeHidden()
})
test('Click on row with 1 keybinding does not expand', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
})
})
test.describe('Double-Click', () => {
test('Double-click row with 0 keybindings opens Add dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Double-click row with 1 keybinding opens Edit dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
})
test.describe('Context Menu', () => {
test('Right-click row shows context menu with correct items', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const addItem = page.getByRole('menuitem', {
name: /Add new keybinding/i
})
const resetItem = page.getByRole('menuitem', {
name: /Reset to default/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toBeVisible()
await expect(addItem).toBeVisible()
await expect(resetItem).toBeVisible()
await expect(removeItem).toBeVisible()
await page.keyboard.press('Escape')
})
test("Context menu 'Add new keybinding' opens add dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
await openContextMenu(page, MULTI_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
await expect(expansionContent).toBeVisible()
})
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F9')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test("Context menu 'Reset to default' resets modified command", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F10')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
})
test('Context menu items disabled when no keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
await openContextMenu(page, NO_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toHaveAttribute('data-disabled', '')
await expect(removeItem).toHaveAttribute('data-disabled', '')
await page.keyboard.press('Escape')
})
})
test.describe('Action Buttons', () => {
test('Edit button opens edit dialog for single-binding command', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const editButton = row.getByRole('button', { name: /^Edit$/i })
await expect(editButton).toBeVisible()
await editButton.click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Add button opens add dialog', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await row.getByRole('button', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Reset button is disabled for unmodified commands', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeDisabled()
})
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F11')
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeEnabled()
await resetButton.click()
await expect(resetButton).toBeDisabled()
})
test('Delete button is disabled for commands with 0 keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeDisabled()
})
test('Delete button removes single keybinding directly', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F12')
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeEnabled()
await deleteButton.click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await deleteButton.click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
await expect(row.locator('td').nth(1)).not.toContainText('-')
})
})
test.describe('Expanded Row Actions', () => {
test('Edit button in expanded row opens edit dialog for that binding', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const firstBindingRow = expansionContent
.getByTestId('keybinding-expansion-binding')
.first()
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Delete button in expanded row removes that binding and collapses', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const bindingRows = expansionContent.getByTestId(
'keybinding-expansion-binding'
)
await expect
.poll(() => bindingRows.count(), {
message: 'Expected at least 2 bindings'
})
.toBeGreaterThanOrEqual(2)
const initialBindingCount = await bindingRows.count()
await bindingRows
.first()
.getByRole('button', { name: /Remove keybinding/i })
.click()
if (initialBindingCount === 2) {
// Expansion auto-collapses when bindings drop below 2
await expect(expansionContent).toBeHidden()
} else {
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
}
})
})
test.describe('Reset All', () => {
test('Reset All button shows confirmation and resets on confirm', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F8')
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
await clearSearch(page)
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(
rowAfterReset.getByRole('button', { name: /Reset/i })
).toBeDisabled()
})
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
const { page } = comfyPage
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
})
})
test.describe('Search Filter', () => {
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
// Changing the filter triggers watch(filters, ...) which clears expansion
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
await expect(expansionContent).toBeHidden()
})
})
})

Some files were not shown because too many files have changed in this diff Show More