Compare commits

..

1 Commits

Author SHA1 Message Date
dante01yoon
673cf2b827 test: add E2E tests for audio upload extension 2026-04-27 14:15:31 +09:00
475 changed files with 6415 additions and 33114 deletions

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -1,156 +0,0 @@
---
name: reviewing-unit-tests
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
---
# Reviewing Unit Tests for ComfyUI_frontend
## Overview
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
## Review Workflow
1. Identify the test type: component, store, composable, util, or bugfix regression.
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
3. Open the authoritative doc section before judging structure.
4. Scan the red flags below.
5. State the verdict first. Name the failure mode. Cite the doc or rule.
## Source of Truth / Precedence
When docs and examples conflict, use this order:
1. Explicit repo rules, lint rules, and note blocks.
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
4. Example snippets
5. Prior diffs
Apply these repo-specific clarifications:
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
- Motion != fix.
## 30-Second Red Flags
| If you see... | Failure mode | Default action |
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
## Rationalization Table
| Excuse | Reality |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
| "It is only a nit" | Explicit repo-rule violations are never nits. |
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
## Mocking Rules
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
### Alias-by-Renaming
```ts
// Before
const mockAdd = vi.hoisted(() => vi.fn())
// After: same indirection, new name
function getToastAddMock() {
return useToast().add
}
```
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
### `vi.mocked()` Scope
| Use case | `vi.mocked()` required? |
| --------------------------------------------------------------- | ----------------------- |
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
| `.mock.calls`, `.mock.results` | Yes |
| `expect(fn).toHaveBeenCalled()` | No |
| `expect(fn).toHaveBeenCalledWith(...)` | No |
- Flag casts whenever `vi.mocked()` would narrow correctly.
- Do not add `vi.mocked()` around assertion-only references just for style.
### Reset Hygiene
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
### Third-Party Seams
- Distinguish trivial hooks from behavior-rich APIs.
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
- That exception does not justify mocking behavior-rich third-party modules.
### `vue-i18n`
- Never mock `vue-i18n` in component tests.
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
## Test-Body Rules
| Smell | Review bar |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Change-detector test | Reject. Default values alone prove nothing. |
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
## Bugfix Regression Proof
For `fix:` PRs or bugfix diffs:
1. Identify the production change that fixes the bug.
2. Verify the new test fails on pre-fix code, or ask the author to show it.
3. If the test passes on broken code, request changes.
A regression test that never proves red does not pin the bug.
## Review Output Rules
- State verdict before procedural questions.
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
- Link the authoritative doc section in the review comment.
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
## Quick Reference
| When you see... | Read this |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
## Key Files to Read
| Purpose | Path |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |

View File

@@ -1,23 +0,0 @@
name: Ashby Pull
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
inputs:
api_key:
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
required: true
job_board_name:
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh Ashby snapshot
shell: bash
env:
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot

View File

@@ -1,87 +0,0 @@
# Outputs default to 'true' for non-pull_request events (push, merge_group):
# granular path filtering is a PR-only optimization. This avoids the silent
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
# would never run on push.
#
# Shared dependency files (root package.json, pnpm-lock.yaml,
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
# output so a lockfile bump correctly invalidates each granular gate. They
# are NOT folded into docs-changes.
#
# Two paths-filter steps are needed because predicate-quantifier=every is
# required for the negated globs in `should-run` but breaks multi-pattern
# OR filters like `docs:` and `deps:`.
#
# Requires the caller to have checked out the repository.
name: 'Detect Path Changes'
description: >
Computes typed *-changes outputs and a back-compat should-run for
path-gated CI jobs.
outputs:
should-run:
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
app-website-changes:
description: 'Shared deps or `apps/website/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
app-desktop-changes:
description: 'Shared deps or `apps/desktop-ui/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
app-frontend-changes:
description: 'Shared deps or `src/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
packages-changes:
description: 'Shared deps or `packages/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
storybook-changes:
description: 'Shared deps or `.storybook/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
docs-changes:
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
dependency-changes:
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
runs:
using: composite
steps:
- name: Filter typed changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
app_website:
- 'apps/website/**'
app_desktop:
- 'apps/desktop-ui/**'
app_frontend:
- 'src/**'
packages:
- 'packages/**'
storybook:
- '.storybook/**'
docs:
- 'docs/**'
- '**/*.md'
deps:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Filter relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: relevant
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
relevant:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'

View File

@@ -12,30 +12,17 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
scan:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -14,29 +14,16 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
validate-fonts:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -81,17 +68,15 @@ jobs:
echo '✅ No proprietary fonts found in dist'
validate-licenses:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -3,8 +3,10 @@ name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
@@ -14,24 +16,12 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
perf-tests:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,21 +16,8 @@ permissions:
contents: read
jobs:
changes:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
collect:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:

View File

@@ -4,6 +4,7 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
merge_group:
@@ -14,20 +15,36 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect whether e2e-relevant files changed. Required checks see "skipped"
# (which counts as passing) when only docs/apps/storybook files are touched,
# avoiding the stall that paths-ignore would cause.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
- name: Checkout repository
if: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@v6
- name: Check for e2e-relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
e2e:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'
setup:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
if: ${{ needs.changes.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -65,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -123,7 +140,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -177,7 +194,7 @@ jobs:
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -216,7 +233,7 @@ jobs:
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
@@ -234,7 +251,7 @@ jobs:
runs-on: ubuntu-latest
if: >-
${{
needs.changes.outputs.should-run == 'true' &&
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
@@ -261,7 +278,7 @@ jobs:
if: >-
${{
always() &&
needs.changes.outputs.should-run == 'true' &&
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}

View File

@@ -8,29 +8,10 @@ on:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -49,13 +30,8 @@ jobs:
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request'
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request'
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -91,15 +67,8 @@ jobs:
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true'))
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -138,15 +107,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, storybook-build]
needs: [storybook-build]
runs-on: ubuntu-latest
if: |
always()
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read

View File

@@ -4,8 +4,10 @@ name: 'CI: Tests Unit'
on:
push:
branches: [main, master, dev*, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
concurrency:
@@ -13,20 +15,7 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
test:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:

View File

@@ -52,9 +52,6 @@ jobs:
run: vercel pull --yes --environment=preview
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: vercel build
- name: Fetch head commit metadata
@@ -149,9 +146,6 @@ jobs:
run: vercel pull --yes --environment=production
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -4,29 +4,23 @@ name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
build:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -36,7 +30,4 @@ jobs:
uses: ./.github/actions/setup-frontend
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build

View File

@@ -2,30 +2,26 @@ name: 'CI: Website E2E'
on:
push:
branches: [main]
branches: [main, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
website-e2e:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
@@ -49,8 +45,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
@@ -167,11 +161,7 @@ jobs:
post-starting-comment:
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
needs: changes
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
pull-requests: write

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,7 +18,6 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
@@ -87,8 +86,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
@@ -140,10 +137,7 @@ jobs:
name: 'Update Website Screenshots'
})
} catch (e) {
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
// Label may already be removed
}
post-starting-comment:

View File

@@ -1,59 +0,0 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
workflow_dispatch:
concurrency:
group: release-website
cancel-in-progress: true
jobs:
refresh-snapshot:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
- name: Refresh Ashby snapshot
uses: ./.github/actions/ashby-pull
with:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
base: main
labels: |
Release:Website
delete-branch: true

View File

@@ -1,13 +1,5 @@
#!/usr/bin/env bash
# Skip in CI: the canonical knip check runs in ci-lint-format on every
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
# setup-comfyui-server, which contaminates knip's project glob with the
# devtools copy under custom_nodes and produces false-positive failures.
if [ -n "${CI-}" ]; then
exit 0
fi
# Run Knip with cache via package script
pnpm knip 1>&2

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-700);
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-600);
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-500);
background-color: var(--color-coral-red-400);
}
.task-div .p-card {

View File

@@ -113,31 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
```html
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
```
The localized `/zh-CN/contact` page uses the same portal and script with form
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
renders the documented embed container.
## Scripts
- `pnpm dev` — Astro dev server

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

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

View File

@@ -1,33 +1,4 @@
# robots.txt for comfy.org
# Open to all crawlers — including AI/LLM bots — for maximum visibility
# in AI-powered search, chat-based answer engines, and traditional search.
# Granular UAs are listed explicitly to signal intent; rules are shared
# via stacked user-agent records (RFC 9309 §2.2).
User-agent: *
User-agent: Googlebot
User-agent: Bingbot
User-agent: DuckDuckBot
User-agent: GPTBot
User-agent: ChatGPT-User
User-agent: OAI-SearchBot
User-agent: Google-Extended
User-agent: ClaudeBot
User-agent: Claude-Web
User-agent: anthropic-ai
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: Applebot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: Amazonbot
User-agent: CCBot
User-agent: Meta-ExternalAgent
User-agent: Meta-ExternalFetcher
User-agent: Diffbot
Allow: /
Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import SectionLabel from '../common/SectionLabel.vue'
import HubspotFormEmbed from './HubspotFormEmbed.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
@@ -16,6 +17,30 @@ function tk(suffix: string): TranslationKey {
return `contact.form.${suffix}` as TranslationKey
}
const firstName = ref('')
const lastName = ref('')
const company = ref('')
const phone = ref('')
const selectedPackage = ref('')
const comfyUsage = ref('')
const lookingFor = ref('')
const packageOptions = [
'packageIndividual',
'packageTeams',
'packageEnterprise'
] as const
const usageOptions = [
'usingYesProduction',
'usingYesTesting',
'usingNotYet',
'usingOtherTools'
] as const
const inputClass =
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
const sectionRef = ref<HTMLElement>()
const badgeRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
@@ -30,6 +55,10 @@ useHeroAnimation({
video: formRef,
parallax: false
})
function handleSubmit() {
// TODO: implement form submission
}
</script>
<template>
@@ -76,7 +105,160 @@ useHeroAnimation({
<!-- Right column: form -->
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
<HubspotFormEmbed :locale />
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- First Name + Last Name -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('firstName'), locale) }}*
</label>
<input
v-model="firstName"
type="text"
required
:placeholder="t(tk('firstNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lastName'), locale) }}*
</label>
<input
v-model="lastName"
type="text"
required
:placeholder="t(tk('lastNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
</div>
<!-- Company + Phone -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('company'), locale) }}*
</label>
<input
v-model="company"
type="text"
required
:placeholder="t(tk('companyPlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('phone'), locale) }}
</label>
<input v-model="phone" type="tel" :class="inputClass" />
</div>
</div>
<!-- Package selection -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('packageQuestion'), locale) }}
</p>
<div class="mt-3 flex flex-wrap gap-3">
<label
v-for="opt in packageOptions"
:key="opt"
:class="
cn(
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
selectedPackage === opt
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
)
"
>
<input
v-model="selectedPackage"
type="radio"
name="package"
:value="opt"
class="sr-only"
/>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
selectedPackage === opt
? 'border-primary-comfy-yellow'
: 'border-primary-warm-gray/40'
)
"
>
<span
v-if="selectedPackage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
{{ t(tk(opt), locale) }}
</label>
</div>
</div>
<!-- Comfy usage -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('usingComfy'), locale) }}
</p>
<div class="mt-3 space-y-3">
<label
v-for="opt in usageOptions"
:key="opt"
class="flex cursor-pointer items-center gap-3"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
comfyUsage === opt
? 'border-primary-comfy-yellow'
: 'border-(--site-border-subtle)'
)
"
>
<span
v-if="comfyUsage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
<input
v-model="comfyUsage"
type="radio"
:value="opt"
class="sr-only"
/>
<span class="text-primary-comfy-canvas text-sm">
{{ t(tk(opt), locale) }}
</span>
</label>
</div>
</div>
<!-- Looking for -->
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lookingFor'), locale) }}
</label>
<textarea
v-model="lookingFor"
:placeholder="t(tk('lookingForPlaceholder'), locale)"
:class="cn(inputClass, 'min-h-24 resize-y')"
/>
</div>
<!-- Submit -->
<div>
<BrandButton type="submit" variant="outline" size="sm">
{{ t(tk('submit'), locale) }}
</BrandButton>
</div>
</form>
</div>
</section>
</template>

View File

@@ -1,126 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
const HUBSPOT_CONTACT_PORTAL_ID = '244637579'
const HUBSPOT_CONTACT_REGION = 'na2'
const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed'
const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js`
const hubspotContactFormIds: Record<Locale, string> = {
en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262',
'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93'
}
const hasEmbedLoadError = ref(false)
const hubspotContactFormId = computed(() => hubspotContactFormIds[locale])
const hubspotFormStyles: Record<`--${string}`, string> = {
'--hsf-global__font-family': "'PP Formula', sans-serif",
'--hsf-global__color': '#c2bfb9',
'--hsf-background__background-color': '#211927',
'--hsf-background__border-width': '0',
'--hsf-background__padding': '0',
'--hsf-button__font-family': "'PP Formula', sans-serif",
'--hsf-button__font-size': '14px',
'--hsf-button__color': '#211927',
'--hsf-button__background-color': '#f2ff59',
'--hsf-button__border-radius': '16px',
'--hsf-button__padding': '10px 24px',
'--hsf-richtext__font-family': "'PP Formula', sans-serif",
'--hsf-richtext__color': '#c2bfb9',
'--hsf-heading__font-family': "'PP Formula', sans-serif",
'--hsf-heading__color': '#c2bfb9',
'--hsf-field-label__font-family': "'PP Formula', sans-serif",
'--hsf-field-label__font-size': '12px',
'--hsf-field-label__color': '#c2bfb9',
'--hsf-field-description__font-family': "'PP Formula', sans-serif",
'--hsf-field-description__color': '#c2bfb9',
'--hsf-field-footer__font-family': "'PP Formula', sans-serif",
'--hsf-field-footer__color': '#c2bfb9',
'--hsf-field-input__font-family': "'PP Formula', sans-serif",
'--hsf-field-input__color': '#c2bfb9',
'--hsf-field-input__background-color': '#2a2230',
'--hsf-field-input__placeholder-color': '#585159',
'--hsf-field-input__border-color': '#3b3539',
'--hsf-field-input__border-width': '1px',
'--hsf-field-input__border-style': 'solid',
'--hsf-field-input__border-radius': '16px',
'--hsf-field-input__padding': '16px',
'--hsf-field-textarea__font-family': "'PP Formula', sans-serif",
'--hsf-field-textarea__color': '#c2bfb9',
'--hsf-field-textarea__background-color': '#2a2230',
'--hsf-field-textarea__placeholder-color': '#585159',
'--hsf-field-textarea__border-color': '#3b3539',
'--hsf-field-textarea__border-width': '1px',
'--hsf-field-textarea__border-style': 'solid',
'--hsf-field-textarea__border-radius': '16px',
'--hsf-field-textarea__padding': '16px',
'--hsf-field-checkbox__color': '#c2bfb9',
'--hsf-field-checkbox__background-color': '#2a2230',
'--hsf-field-checkbox__border-color': '#464147',
'--hsf-field-checkbox__border-width': '1px',
'--hsf-field-checkbox__border-style': 'solid',
'--hsf-field-radio__color': '#c2bfb9',
'--hsf-field-radio__background-color': '#2a2230',
'--hsf-field-radio__border-color': '#464147',
'--hsf-field-radio__border-width': '1px',
'--hsf-field-radio__border-style': 'solid',
'--hsf-erroralert__font-family': "'PP Formula', sans-serif",
'--hsf-infoalert__font-family': "'PP Formula', sans-serif"
}
onMounted(() => {
if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return
const script = document.createElement('script')
script.id = HUBSPOT_CONTACT_SCRIPT_ID
script.src = HUBSPOT_CONTACT_SCRIPT_SRC
script.defer = true
script.addEventListener(
'error',
() => {
hasEmbedLoadError.value = true
script.remove()
},
{ once: true }
)
document.head.append(script)
})
</script>
<template>
<div class="min-h-[640px] w-full">
<p
v-if="hasEmbedLoadError"
class="text-primary-comfy-canvas text-sm/6"
role="status"
>
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
<a
class="text-primary-comfy-yellow underline"
href="mailto:hello@comfy.org"
>
hello@comfy.org
</a>
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
</p>
<div
v-else
:key="hubspotContactFormId"
class="hs-form-html"
:style="hubspotFormStyles"
:data-region="HUBSPOT_CONTACT_REGION"
:data-form-id="hubspotContactFormId"
:data-portal-id="HUBSPOT_CONTACT_PORTAL_ID"
/>
</div>
</template>

View File

@@ -4,12 +4,12 @@ const translations = {
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
'zh-CN': '视觉 AI 的\n最强可控性'
'zh-CN': '视觉 AI 的\n专业控制'
},
'hero.subtitle': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
},
// ProductShowcaseSection
@@ -20,11 +20,11 @@ const translations = {
},
'showcase.subtitle2': {
en: 'Start from a community template or build from scratch.',
'zh-CN': '从工作流模板开始,或从零构建。'
'zh-CN': '从社区模板开始,或从零构建。'
},
'showcase.feature1.title': {
en: 'Full Control with Nodes',
'zh-CN': '节点带来的可控性'
'zh-CN': '节点式完全控制'
},
'showcase.feature1.description': {
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
@@ -49,8 +49,8 @@ const translations = {
'zh-CN':
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
},
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运' },
// UseCaseSection
'useCase.label': {
@@ -83,7 +83,8 @@ const translations = {
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
'zh-CN':
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
},
'useCase.cta': {
en: 'EXPLORE WORKFLOWS',
@@ -163,7 +164,7 @@ const translations = {
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版性'
'zh-CN': '查看本地版性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -175,7 +176,7 @@ const translations = {
},
'products.cloud.cta': {
en: 'SEE CLOUD FEATURES',
'zh-CN': '查看云端性'
'zh-CN': '查看云端性'
},
'products.api.title': {
en: 'Comfy\nAPI',
@@ -187,7 +188,7 @@ const translations = {
},
'products.api.cta': {
en: 'SEE API FEATURES',
'zh-CN': '查看 API 性'
'zh-CN': '查看 API 性'
},
'products.enterprise.title': {
en: 'Comfy\nEnterprise',
@@ -199,7 +200,7 @@ const translations = {
},
'products.enterprise.cta': {
en: 'SEE ENTERPRISE FEATURES',
'zh-CN': '查看企业版性'
'zh-CN': '查看企业版性'
},
// CaseStudySpotlightSection
@@ -1214,7 +1215,7 @@ const translations = {
'pricing.included.feature4.description': {
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
'zh-CN':
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro。'
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro。'
},
'pricing.included.feature5.title': {
en: 'Add more credits anytime',
@@ -1244,12 +1245,12 @@ const translations = {
},
'pricing.included.feature8.title': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
'zh-CN': '合作节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',
@@ -3298,13 +3299,82 @@ const translations = {
en: 'Find your answer here',
'zh-CN': '在这里找到答案'
},
'contact.form.embedLoadErrorPrefix': {
en: 'Unable to load the contact form. Email us at',
'zh-CN': '联系表单无法加载。请发送邮件至'
'contact.form.firstName': {
en: 'First name',
'zh-CN': ''
},
'contact.form.embedLoadErrorSuffix': {
en: "and we'll route your request.",
'zh-CN': '我们会为您处理请求。'
'contact.form.lastName': {
en: 'Last Name',
'zh-CN': ''
},
'contact.form.company': {
en: 'Company',
'zh-CN': '公司'
},
'contact.form.phone': {
en: 'Phone Number (optional)',
'zh-CN': '电话号码(可选)'
},
'contact.form.packageQuestion': {
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
'zh-CN':
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K、个人套餐或团队套餐'
},
'contact.form.packageIndividual': {
en: 'INDIVIDUAL',
'zh-CN': '个人'
},
'contact.form.packageTeams': {
en: 'TEAMS',
'zh-CN': '团队'
},
'contact.form.packageEnterprise': {
en: 'ENTERPRISE',
'zh-CN': '企业'
},
'contact.form.usingComfy': {
en: 'Are you /your team currently using Comfy?',
'zh-CN': '您/您的团队目前是否在使用 Comfy'
},
'contact.form.usingYesProduction': {
en: 'Yes, in production',
'zh-CN': '是,在生产环境中'
},
'contact.form.usingYesTesting': {
en: 'Yes, testing / experimenting',
'zh-CN': '是,测试/实验中'
},
'contact.form.usingNotYet': {
en: 'Not yet, evaluating',
'zh-CN': '尚未使用,评估中'
},
'contact.form.usingOtherTools': {
en: 'Not using Comfy yet, but using other GenAI tools',
'zh-CN': '尚未使用 Comfy但在使用其他 GenAI 工具'
},
'contact.form.lookingFor': {
en: 'What are you looking for?',
'zh-CN': '您在寻找什么?'
},
'contact.form.lookingForPlaceholder': {
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
},
'contact.form.submit': {
en: 'SUBMIT',
'zh-CN': '提交'
},
'contact.form.firstNamePlaceholder': {
en: 'Jane',
'zh-CN': 'Jane'
},
'contact.form.lastNamePlaceholder': {
en: 'Smith',
'zh-CN': 'Smith'
},
'contact.form.companyPlaceholder': {
en: 'jane@acme.org',
'zh-CN': 'jane@acme.org'
},
'customers.story.whatsNext': {

View File

@@ -133,15 +133,9 @@ const websiteJsonLd = {
<script>
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
import { ScrollTrigger } from '../scripts/gsapSetup'
import { initPostHog, capturePageview } from '../scripts/posthog'
initSmoothScroll()
if (import.meta.env.PROD) {
initPostHog()
document.addEventListener('astro:page-load', capturePageview)
}
document.addEventListener('astro:page-load', () => {
ScrollTrigger.refresh()
})

View File

@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -1,36 +0,0 @@
import posthog from 'posthog-js'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
const POSTHOG_API_HOST =
import.meta.env.PUBLIC_POSTHOG_API_HOST ?? 'https://t.comfy.org'
const POSTHOG_UI_HOST =
import.meta.env.PUBLIC_POSTHOG_UI_HOST ?? 'https://us.posthog.com'
let initialized = false
export function initPostHog() {
if (initialized || typeof window === 'undefined' || !POSTHOG_KEY) return
try {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_API_HOST,
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
})
initialized = true
} catch (error) {
console.error('PostHog init failed', error)
}
}
export function capturePageview() {
if (!initialized) return
try {
posthog.capture('$pageview')
} catch (error) {
console.error('PostHog pageview capture failed', error)
}
}

View File

@@ -1,36 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
else process.env.WEBSITE_GITHUB_STARS_OVERRIDE = savedOverride
})
it('uses the build-time override without calling GitHub', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
})
describe('formatStarCount', () => {
it('formats the visual-test override to match committed snapshots', () => {
expect(formatStarCount(110000)).toBe('110K')
})
})

View File

@@ -2,9 +2,6 @@ export async function fetchGitHubStars(
owner: string,
repo: string
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
@@ -28,17 +25,3 @@ export function formatStarCount(count: number): string {
}
return count.toString()
}
function readGitHubStarsOverride(): number | undefined {
const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
if (rawCount === undefined || rawCount === '') return undefined
const count = Number(rawCount)
if (!Number.isSafeInteger(count) || count < 0) {
throw new Error(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
}
return count
}

View File

@@ -7,15 +7,6 @@
"github": {
"enabled": false
},
"headers": [
{
"source": "/(.*)",
"has": [
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
],
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
}
],
"redirects": [
{
"source": "/pricing",

View File

@@ -15,15 +15,11 @@ browser_tests/
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object classes (locators, user interactions)
│ │ ├── Actionbar.ts
│ ├── components/ - Page object components (locators, user interactions)
│ │ ├── ContextMenu.ts
│ │ ├── ManageGroupNode.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ ── Templates.ts
│ │ ├── Topbar.ts
│ │ └── ...
│ │ ── Topbar.ts
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
@@ -32,36 +28,17 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
│ ├── builderTestUtils.ts
│ ├── clipboardSpy.ts
│ ├── fitToView.ts
│ ├── perfReporter.ts
│ └── ...
│ └── utils/ - Pure utility functions (no page dependency)
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
### Placement Rule
When adding a new file, use this decision tree:
```mermaid
flowchart TD
A[New file in browser_tests/fixtures/] --> B{Has any code?}
B -- No, JSON/data only --> D[fixtures/data/]
B -- Yes --> C{Is it a class?}
C -- No, exported functions --> U[fixtures/utils/]
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
E -- Yes --> P[fixtures/components/]
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
```
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
## Page Object Locator Style

View File

@@ -51,7 +51,6 @@ DISABLE_VUE_PLUGINS=true
# Test against dev server (recommended) or backend directly
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
# Path to ComfyUI for backing up user data/settings before tests
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
@@ -96,17 +95,6 @@ pnpm test:browser:local # Run all tests
pnpm test:browser:local widget.spec.ts # Run specific test file
```
### Slowing the browser down for debugging
When running with `--headed` (or `--ui`), set `SLOW_MO` to a millisecond delay
to slow every Playwright action down so you can watch what is happening. The
delay only applies when `PLAYWRIGHT_LOCAL` is set (the default for the
`pnpm test:browser:local` script).
```bash
SLOW_MO=250 pnpm test:browser:local --headed widget.spec.ts
```
## Test Structure
Browser tests in this project follow a specific organization pattern:
@@ -151,9 +139,12 @@ Always check for existing helpers and fixtures before implementing new ones:
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
- **ComfyMouse**: Helper for precise mouse operations on the canvas
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
- `actionbar.ts`: Interact with the action bar
- `manageGroupNode.ts`: Group node management operations
- `templates.ts`: Template workflows operations
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.

View File

@@ -1,27 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Preview3D",
"pos": [50, 50],
"size": [450, 600],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "Preview3D",
"Last Time Model File": "nonexistent_model.glb"
},
"widgets_values": ["nonexistent_model.glb"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "offset": [0, 0], "scale": 1 } },
"version": 0.4
}

View File

@@ -119,15 +119,7 @@
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
],
"properties": {
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors",
"directory": "checkpoints"
}
]
},
"properties": {},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
}
],

View File

@@ -0,0 +1,284 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [210, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [210, 168],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [210, 168],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,74 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [50, 50],
"size": [400, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 1,
"type": "Painter",
"pos": [450, 50],
"size": [450, 550],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,4 +1,4 @@
import type { Locator, Mouse } from '@playwright/test'
import type { Mouse } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
@@ -72,22 +72,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.nextFrame()
}
async resizeByDragging(
element: Locator,
{ x, y }: { x?: number; y?: number }
) {
const elementBox = await element.boundingBox()
if (!elementBox) throw new Error('element should have layout')
const cx = elementBox.x + elementBox.width / 2
const cy = elementBox.y + elementBox.height / 2
await this.dragAndDrop(
{ x: cx, y: cy },
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
)
}
//#region Pass-through
async click(...args: Parameters<Mouse['click']>) {
return await this.mouse.click(...args)

View File

@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
import { ComfyActionbar } from '@e2e/helpers/actionbar'
import { ComfyTemplates } from '@e2e/helpers/templates'
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
@@ -22,7 +22,6 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import {
AssetsSidebarTab,
ModelLibrarySidebarTab,
@@ -55,13 +54,11 @@ class ComfyPropertiesPanel {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
}
}
@@ -140,7 +137,6 @@ class ComfyMenu {
export class ComfyPage {
public readonly url: string
public readonly apiUrl: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
public readonly selectionToolbox: Locator
@@ -163,7 +159,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly titleEditor: TitleEditor
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
@@ -200,7 +195,6 @@ export class ComfyPage {
public readonly request: APIRequestContext
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
this.canvas = page.locator('#graph-canvas')
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
@@ -210,14 +204,13 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.titleEditor = new TitleEditor(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
@@ -243,7 +236,7 @@ export class ComfyPage {
}
async setupUser(username: string) {
const res = await this.request.get(`${this.apiUrl}/api/users`)
const res = await this.request.get(`${this.url}/api/users`)
if (res.status() !== 200)
throw new Error(`Failed to retrieve users: ${await res.text()}`)
@@ -257,7 +250,7 @@ export class ComfyPage {
}
async createUser(username: string) {
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
const resp = await this.request.post(`${this.url}/api/users`, {
data: { username }
})
@@ -269,7 +262,7 @@ export class ComfyPage {
async setupSettings(settings: Record<string, unknown>) {
const resp = await this.request.post(
`${this.apiUrl}/api/devtools/set_settings`,
`${this.url}/api/devtools/set_settings`,
{
data: settings
}

View File

@@ -30,13 +30,6 @@ export class VueNodeHelpers {
return this.page.locator(`[data-node-id="${nodeId}"]`)
}
/**
* Get the inner wrapper element of a Vue node.
*/
getNodeInnerWrapper(nodeId: string): Locator {
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
}
/**
* 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.
@@ -126,9 +119,10 @@ export class VueNodeHelpers {
}
/**
* Resolve the data-node-id of the first rendered node matching the title.
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
*/
async getNodeIdByTitle(title: string): Promise<string> {
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
const node = this.getNodeByTitle(title).first()
await node.waitFor({ state: 'visible' })
@@ -139,15 +133,6 @@ export class VueNodeHelpers {
)
}
return nodeId
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
const nodeId = await this.getNodeIdByTitle(title)
return new VueNodeFixture(this.getNodeLocator(nodeId))
}

View File

@@ -1,112 +1,29 @@
import type { Locator } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const { searchBoxV2 } = TestIds
export type { RootCategoryId }
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
readonly filterChips: Locator
readonly noResults: Locator
readonly nodeIdBadge: Locator
readonly sidebarToggle: Locator
readonly sidebarBackdrop: Locator
readonly filterChipsScroll: Locator
constructor(private comfyPage: ComfyPage) {
const page = comfyPage.page
constructor(readonly page: Page) {
this.dialog = page.getByRole('search')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle)
this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop)
this.filterChipsScroll = this.dialog.getByTestId(
searchBoxV2.filterChipsScroll
)
this.input = this.dialog.locator('input[type="text"]')
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
}
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
categoryButton(categoryId: string): Locator {
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
return this.dialog.getByTestId(`category-${categoryId}`)
}
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
rootCategoryButton(id: RootCategoryId): Locator {
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
filterBarButton(name: string): Locator {
return this.dialog.getByRole('button', { name })
}
/** Top filter-bar input/output type popover trigger. */
typeFilterButton(key: 'input' | 'output'): Locator {
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
}
async applyTypeFilter(
key: 'input' | 'output',
typeName: string
): Promise<void> {
const trigger = this.typeFilterButton(key)
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'visible' })
await this.filterSearch.fill(typeName)
await this.filterOptions.filter({ hasText: typeName }).first().click()
// The popover does not auto-close on selection — toggle the trigger.
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'hidden' })
}
async removeFilterChip(index = 0): Promise<void> {
await this.filterChips
.nth(index)
.getByTestId(searchBoxV2.chipDelete)
.click()
}
async toggle(): Promise<void> {
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
}
async open(): Promise<void> {
if (await this.input.isVisible()) return
await this.toggle()
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
await this.comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
}
async setup(): Promise<void> {
await this.ensureV2Search()
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
}
}

View File

@@ -4,13 +4,11 @@ import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
}
@@ -41,10 +39,7 @@ export class ContextMenu {
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
const litegraphContextVisible = await this.litegraphContextMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible || litegraphContextVisible
return primeVueVisible || litegraphVisible
}
async assertHasItems(items: string[]): Promise<void> {
@@ -76,8 +71,7 @@ export class ContextMenu {
async waitForHidden(): Promise<void> {
await Promise.all([
this.primeVueMenu.waitFor({ state: 'hidden' }),
this.litegraphMenu.waitFor({ state: 'hidden' }),
this.litegraphContextMenu.waitFor({ state: 'hidden' })
this.litegraphMenu.waitFor({ state: 'hidden' })
])
}
}

View File

@@ -1,72 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
import { TestIds } from '@e2e/fixtures/selectors'
export class PublishDialog extends BaseDialog {
readonly nav: Locator
readonly footer: Locator
readonly savePrompt: Locator
readonly describeStep: Locator
readonly finishStep: Locator
readonly profilePrompt: Locator
readonly gateFlow: Locator
readonly nameInput: Locator
readonly descriptionTextarea: Locator
readonly tagsInput: Locator
readonly backButton: Locator
readonly nextButton: Locator
readonly publishButton: Locator
constructor(page: Page) {
super(page, TestIds.publish.dialog)
this.nav = this.root.getByTestId(TestIds.publish.nav)
this.footer = this.root.getByTestId(TestIds.publish.footer)
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
this.descriptionTextarea = this.describeStep.locator('textarea')
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
this.backButton = this.footer.getByRole('button', { name: 'Back' })
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
this.publishButton = this.footer.getByRole('button', {
name: 'Publish to ComfyHub'
})
}
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
// dev and production, rather than clicking through the UI.
async open(): Promise<void> {
await this.page.evaluate(async () => {
await window.app!.extensionManager.dialog.showPublishDialog()
})
await this.waitForVisible()
}
tagSuggestion(name: string): Locator {
return this.describeStep.getByText(name, { exact: true })
}
navStep(label: string): Locator {
return this.nav.getByRole('button', { name: label })
}
currentNavStep(): Locator {
return this.nav.locator('[aria-current="step"]')
}
async goNext(): Promise<void> {
await this.nextButton.click()
}
async goBack(): Promise<void> {
await this.backButton.click()
}
async goToStep(label: string): Promise<void> {
await this.navStep(label).click()
}
}

View File

@@ -1,33 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The node/group title-editing input. Rendered in three scopes: the canvas
* overlay (page-wide), the properties panel, and the Vue node itself.
*/
export class TitleEditor {
public readonly input: Locator
constructor(scope: Page | Locator) {
this.input = scope.getByTestId(TestIds.node.titleInput)
}
async setTitle(title: string): Promise<void> {
await this.input.fill(title)
await this.input.press('Enter')
}
async cancel(): Promise<void> {
await this.input.press('Escape')
}
async expectVisible(): Promise<void> {
await expect(this.input).toBeVisible()
}
async expectHidden(): Promise<void> {
await expect(this.input).toBeHidden()
}
}

View File

@@ -1,54 +0,0 @@
import type { Locator } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
class BoundingBoxCoordinate {
public readonly root: Locator
public readonly input: Locator
public readonly incrementButton: Locator
public readonly decrementButton: Locator
constructor(root: Locator) {
this.root = root
this.input = root.locator('input')
this.incrementButton = root.getByTestId(TestIds.widgets.increment)
this.decrementButton = root.getByTestId(TestIds.widgets.decrement)
}
async type(value: string | number): Promise<void> {
await this.input.fill(String(value))
await this.input.press('Enter')
}
async focus(): Promise<void> {
await this.input.focus()
}
async increment(): Promise<void> {
await this.incrementButton.click()
}
async decrement(): Promise<void> {
await this.decrementButton.click()
}
}
export class WidgetBoundingBoxFixture {
public readonly root: Locator
public readonly x: BoundingBoxCoordinate
public readonly y: BoundingBoxCoordinate
public readonly width: BoundingBoxCoordinate
public readonly height: BoundingBoxCoordinate
constructor(parent: Locator) {
this.root = parent.getByTestId('bounding-box')
this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x'))
this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y'))
this.width = new BoundingBoxCoordinate(
this.root.getByTestId('bounding-box-width')
)
this.height = new BoundingBoxCoordinate(
this.root.getByTestId('bounding-box-height')
)
}
}

View File

@@ -1,19 +1,9 @@
import type { Page, Route } from '@playwright/test'
import type {
CreateAssetExportData,
CreateAssetExportResponse,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = '**/api/jobs?*'
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
const assetExportRoutePattern = '**/api/assets/export'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
@@ -168,23 +158,12 @@ function getExecutionDuration(job: RawJobListItem): number {
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
null
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private cloudAssetsResponse: ListAssetsResponse | null = null
private assetExportRequests: CreateAssetExportData['body'][] = []
private assetExportResponse: CreateAssetExportResponse | null = null
private importedFiles: string[] = []
private readonly jobDetailRouteHandlers = new Map<
string,
(route: Route) => Promise<void>
>()
constructor(private readonly page: Page) {}
@@ -261,82 +240,6 @@ export class AssetsHelper {
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
this.cloudAssetsResponse = response
if (this.cloudAssetsRouteHandler) {
return
}
this.cloudAssetsRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.cloudAssetsResponse)
})
}
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
}
async mockEmptyCloudAssets(): Promise<void> {
await this.mockCloudAssets({
assets: [],
total: 0,
has_more: false
})
}
async captureAssetExportRequests(
response: CreateAssetExportResponse = {
task_id: 'asset-export-task',
status: 'created'
}
): Promise<CreateAssetExportData['body'][]> {
this.assetExportRequests = []
this.assetExportResponse = response
if (this.assetExportRouteHandler) {
return this.assetExportRequests
}
this.assetExportRouteHandler = async (route: Route) => {
this.assetExportRequests.push(
route.request().postDataJSON() as CreateAssetExportData['body']
)
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(this.assetExportResponse)
})
}
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
return this.assetExportRequests
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
if (existingHandler) {
await this.page.unroute(pattern, existingHandler)
}
const handler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(detail)
})
}
this.jobDetailRouteHandlers.set(pattern, handler)
await this.page.route(pattern, handler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
@@ -392,9 +295,6 @@ export class AssetsHelper {
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.cloudAssetsResponse = null
this.assetExportRequests = []
this.assetExportResponse = null
this.importedFiles = []
if (this.jobsRouteHandler) {
@@ -402,22 +302,6 @@ export class AssetsHelper {
this.jobsRouteHandler = null
}
if (this.cloudAssetsRouteHandler) {
await this.page.unroute(
assetsListRoutePattern,
this.cloudAssetsRouteHandler
)
this.cloudAssetsRouteHandler = null
}
if (this.assetExportRouteHandler) {
await this.page.unroute(
assetExportRoutePattern,
this.assetExportRouteHandler
)
this.assetExportRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
@@ -433,10 +317,5 @@ export class AssetsHelper {
)
this.deleteHistoryRouteHandler = null
}
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
await this.page.unroute(pattern, handler)
}
this.jobDetailRouteHandlers.clear()
}
}

View File

@@ -74,7 +74,7 @@ export class CanvasHelper {
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
async toAbsolute(position: Position): Promise<Position> {
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
@@ -150,28 +150,6 @@ export class CanvasHelper {
await nextFrame(this.page)
}
async getOffset(): Promise<[number, number]> {
return this.page.evaluate(
() => [...window.app!.canvas.ds.offset] as [number, number]
)
}
async getNodeTitleHeight(): Promise<number> {
return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT)
}
/**
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
* coordinates.
*/
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
await this.page.keyboard.down('Control')
await this.page.keyboard.down('Shift')
await this.dragAndDrop(from, to)
await this.page.keyboard.up('Shift')
await this.page.keyboard.up('Control')
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
@@ -264,39 +242,11 @@ export class CanvasHelper {
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
): Promise<void> {
const { modifiers = [] } = options
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
}
async middleClick(position: Position): Promise<void> {
await this.mouseClickAt(position, { button: 'middle' })
}
async dblclickGroupTitle(title: string): Promise<void> {
const clientPos = await this.page.evaluate((targetTitle) => {
const groups = window.app!.canvas.graph?.groups ?? []
const group = groups.find(
(g: { title: string }) => g.title === targetTitle
)
if (!group) return null
const cx = group.pos[0] + group.size[0] / 2
const cy = group.pos[1] + group.titleHeight / 2
return window.app!.canvasPosToClientPos([cx, cy])
}, title)
if (!clientPos) throw new Error(`Group "${title}" not found`)
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
await nextFrame(this.page)
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {

View File

@@ -4,7 +4,7 @@ import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
export class ClipboardHelper {
constructor(

View File

@@ -13,11 +13,7 @@ import type { Page } from '@playwright/test'
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
private readonly appUrl: string
constructor(private readonly page: Page) {
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
}
constructor(private readonly page: Page) {}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
@@ -38,7 +34,7 @@ export class CloudAuthHelper {
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto(`${this.appUrl}/api/users`)
await this.page.goto('http://localhost:8188/api/users')
await this.page.evaluate(() => {
const MOCK_USER_DATA = {

View File

@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'

View File

@@ -1,35 +1,9 @@
import type { WebSocketRoute } from '@playwright/test'
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'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
*/
export function buildKSamplerError(
type: NodeError['errors'][number]['type'],
inputName: string,
message: string
): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type,
message,
details: '',
extra_info: { input_name: inputName }
}
]
}
}
/**
* Helper for simulating prompt execution in e2e tests.
*/
@@ -42,23 +16,13 @@ export class ExecutionHelper {
constructor(
comfyPage: ComfyPage,
private readonly ws?: WebSocketRoute
private readonly ws: WebSocketRoute
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
}
private requireWs(): WebSocketRoute {
if (!this.ws) {
throw new Error(
'ExecutionHelper was constructed without a WebSocketRoute; ' +
'pass `ws` to use methods that send WS frames.'
)
}
return this.ws
}
/**
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
* the synthetic job ID.
@@ -75,7 +39,7 @@ export class ExecutionHelper {
})
await this.page.route(
PROMPT_ROUTE_PATTERN,
'**/api/prompt',
async (route) => {
await route.fulfill({
status: 200,
@@ -96,31 +60,6 @@ export class ExecutionHelper {
return jobId
}
async mockValidationFailure(
nodeErrors: Record<string, NodeError>
): Promise<void> {
const response: PromptResponse = {
node_errors: nodeErrors,
error: {
type: 'prompt_outputs_failed_validation',
message: 'Prompt outputs failed validation',
details: ''
}
}
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify(response)
})
},
{ times: 1 }
)
}
/**
* Send a binary `b_preview_with_metadata` WS message (type 4).
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
@@ -150,12 +89,12 @@ export class ExecutionHelper {
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.requireWs().send(Buffer.from(buf))
this.ws.send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
@@ -165,7 +104,7 @@ export class ExecutionHelper {
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
@@ -179,7 +118,7 @@ export class ExecutionHelper {
nodeId: string,
output: Record<string, unknown>
): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'executed',
data: {
@@ -194,7 +133,7 @@ export class ExecutionHelper {
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
@@ -204,7 +143,7 @@ export class ExecutionHelper {
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'execution_error',
data: {
@@ -222,7 +161,7 @@ export class ExecutionHelper {
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
@@ -262,7 +201,7 @@ export class ExecutionHelper {
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.requireWs().send(
this.ws.send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }

View File

@@ -1,10 +1,6 @@
import type { Locator } from '@playwright/test'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
@@ -43,48 +39,6 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and position is provided, a synthetic MouseEvent is created as the
* dragEvent.
* @param position - When ghost is true, client coordinates for the ghost
* placement dragEvent. Otherwise, world coordinates assigned to node.pos.
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
position?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, pos]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && pos) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: pos.x,
clientY: pos.y
})
} else if (pos) {
node.pos = [pos.x, pos.y]
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, position ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Remove all nodes from the graph and clean. */
async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)

View File

@@ -1,231 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
AssetInfo,
HubAssetUploadUrlResponse,
HubLabelInfo,
HubLabelListResponse,
HubProfile,
WorkflowPublishInfo
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
const DEFAULT_PROFILE: HubProfile = {
username: 'testuser',
display_name: 'Test User',
description: 'A test creator',
avatar_url: undefined
}
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
{ name: 'anime', display_name: 'anime', type: 'tag' },
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
]
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
workflow_id: 'test-workflow-id-456',
share_id: 'test-share-id-123',
publish_time: new Date().toISOString(),
listed: true,
assets: []
}
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
upload_url: 'https://mock-s3.example.com/upload',
public_url: 'https://mock-s3.example.com/asset.png',
token: 'mock-upload-token'
}
export class PublishApiHelper {
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(private readonly page: Page) {}
async mockProfile(profile: HubProfile | null): Promise<void> {
await this.addRoute('**/hub/profiles/me', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (profile === null) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(profile)
})
}
})
}
async mockTagLabels(
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
): Promise<void> {
const response: HubLabelListResponse = { labels }
await this.addRoute('**/hub/labels**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishStatus(
status: 'unpublished' | WorkflowPublishInfo
): Promise<void> {
await this.addRoute('**/userdata/*/publish', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (status === 'unpublished') {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(status)
})
}
})
}
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
const response: ShareableAssetsResponse = { assets }
await this.addRoute('**/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflow(
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflowError(
statusCode = 500,
message = 'Failed to publish workflow'
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: statusCode,
contentType: 'application/json',
body: JSON.stringify({ message })
})
})
}
async mockUploadUrl(
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
): Promise<void> {
await this.addRoute('**/hub/assets/upload-url', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async setupDefaultMocks(options?: {
hasProfile?: boolean
hasPrivateAssets?: boolean
}): Promise<void> {
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
await this.mockTagLabels()
await this.mockPublishStatus('unpublished')
await this.mockShareableAssets(
hasPrivateAssets
? [
{
id: 'asset-1',
name: 'my_model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: true
}
]
: []
)
await this.mockPublishWorkflow()
await this.mockUploadUrl()
}
async cleanup(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
}
private async addRoute(
pattern: string,
handler: (route: Route) => Promise<void>
): Promise<void> {
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
private async removeRoutes(pattern: string): Promise<void> {
const handlers = this.routeHandlers.filter(
(route) => route.pattern === pattern
)
for (const { handler } of handlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = this.routeHandlers.filter(
(route) => route.pattern !== pattern
)
}
}
export const publishFixture = comfyPageFixture.extend<{
publishApi: PublishApiHelper
publishDialog: PublishDialog
}>({
publishApi: async ({ comfyPage }, use) => {
const helper = new PublishApiHelper(comfyPage.page)
await use(helper)
await helper.cleanup()
},
publishDialog: async ({ comfyPage }, use) => {
await use(new PublishDialog(comfyPage.page))
}
})

View File

@@ -59,9 +59,6 @@ export const TestIds = {
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
@@ -86,11 +83,7 @@ export const TestIds = {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button',
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
actionBarButtons: 'action-bar-buttons'
subscribeButton: 'topbar-subscribe-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -210,26 +203,12 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',
notificationBanner: 'queue-notification-banner'
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
publish: {
dialog: 'publish-dialog',
savePrompt: 'publish-save-prompt',
describeStep: 'publish-describe-step',
finishStep: 'publish-finish-step',
footer: 'publish-footer',
profilePrompt: 'publish-profile-prompt',
nav: 'publish-nav',
gateFlow: 'publish-gate-flow',
nameInput: 'publish-name-input',
tagsInput: 'publish-tags-input'
},
loading: {
overlay: 'loading-overlay'
},
@@ -255,20 +234,6 @@ export const TestIds = {
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
},
searchBoxV2: {
resultItem: 'result-item',
filterOption: 'filter-option',
filterChip: 'filter-chip',
chipDelete: 'chip-delete',
noResults: 'no-results',
nodeIdBadge: 'node-id-badge',
sidebarToggle: 'toggle-category-sidebar',
sidebarBackdrop: 'sidebar-backdrop',
filterChipsScroll: 'filter-chips-scroll',
category: (id: string) => `category-${id}`,
rootCategory: (id: string) => `search-category-${id}`,
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
}
} as const

View File

@@ -1,8 +1,7 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
@@ -170,36 +169,6 @@ class NodeSlotReference {
[this.type, this.node.id, this.index] as const
)
}
async getLink(): Promise<SerialisableLLink | null> {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const graph = window.app!.canvas.graph!
const node = graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const linkId =
type === 'input'
? node.inputs[index].link
: (node.outputs[index].links ?? [])[0]
if (linkId == null) return null
const link =
graph.links instanceof Map
? graph.links.get(linkId)
: graph.links[linkId]
if (!link) return null
return {
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
target_id: link.target_id,
target_slot: link.target_slot,
type: link.type,
parentId: link.parentId
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
@@ -357,23 +326,6 @@ export class NodeReference {
const nodeSize = await this.getSize()
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
}
async dragBy(
delta: Position,
options?: {
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const titlePos = await this.getTitlePosition()
const target = { x: titlePos.x + delta.x, y: titlePos.y + delta.y }
const modifiers = options?.modifiers ?? []
const keyboard = this.comfyPage.page.keyboard
for (const mod of modifiers) await keyboard.down(mod)
try {
await this.comfyPage.canvasOps.dragAndDrop(titlePos, target)
} finally {
for (const mod of modifiers) await keyboard.up(mod)
}
}
async isPinned() {
return !!(await this.getFlags()).pinned
}

View File

@@ -1,13 +1,12 @@
import type { Locator } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
public readonly title: Locator
public readonly titleEditor: TitleEditor
public readonly titleInput: Locator
public readonly body: Locator
public readonly pinIndicator: Locator
public readonly collapseButton: Locator
@@ -17,7 +16,7 @@ export class VueNodeFixture {
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
this.title = locator.getByTestId('node-title')
this.titleEditor = new TitleEditor(locator)
this.titleInput = locator.getByTestId('node-title-input')
this.body = locator.locator('[data-testid^="node-body-"]')
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
this.collapseButton = locator.getByTestId('node-collapse-button')
@@ -31,8 +30,17 @@ export class VueNodeFixture {
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(value)
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.fill(value)
await input.press('Enter')
}
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.press('Escape')
}
async toggleCollapse(): Promise<void> {

View File

@@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig'
import { writePerfReport } from '@e2e/fixtures/utils/perfReporter'
import { writePerfReport } from '@e2e/helpers/perfReporter'
import { restorePath } from '@e2e/utils/backupUtils'
dotenvConfig()

View File

@@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
interface BuilderSetupResult {
inputNodeTitle: string

View File

@@ -56,13 +56,7 @@ export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
let entries
try {
entries = readdirSync('test-results', { withFileTypes: true })
} catch {
return
}
if (!entries.length) return
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let tempFiles: string[]
try {

View File

@@ -1,6 +1,6 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (

View File

@@ -1,140 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const ICON_CLASS = 'icon-[lucide--star]'
const BUTTON_LABEL = 'Test Action'
const BUTTON_TOOLTIP = 'Test action tooltip'
async function registerTestButton(
page: Page,
opts: {
name?: string
icon?: string
label?: string
tooltip?: string
} = {}
): Promise<void> {
await page.evaluate(
({ name, icon, label, tooltip }) => {
window.app!.registerExtension({
name,
actionBarButtons: [{ icon, label, tooltip, onClick: () => {} }]
})
},
{
name: opts.name ?? 'TestActionBarButton',
icon: opts.icon ?? ICON_CLASS,
label: opts.label ?? BUTTON_LABEL,
tooltip: opts.tooltip ?? BUTTON_TOOLTIP
}
)
}
test.describe('ActionBar Buttons', { tag: ['@ui'] }, () => {
test.describe('Empty state', () => {
test('container is hidden when no extension registers buttons', async ({
comfyPage
}) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.actionBarButtons)
).toBeHidden()
})
})
test.describe('Button rendering', () => {
test('registered button is visible with correct label', async ({
comfyPage
}) => {
await registerTestButton(comfyPage.page)
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(container).toBeVisible()
await expect(
container.getByRole('button', { name: BUTTON_TOOLTIP })
).toBeVisible()
await expect(container.getByText(BUTTON_LABEL)).toBeVisible()
})
test('button icon is rendered', async ({ comfyPage }) => {
await registerTestButton(comfyPage.page)
const icon = comfyPage.page
.getByTestId(TestIds.topbar.actionBarButtons)
.getByRole('button', { name: BUTTON_TOOLTIP })
.locator('i')
await expect(icon).toHaveClass(ICON_CLASS)
})
test('multiple registered buttons all appear', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestActionBarButtons',
actionBarButtons: [
{
icon: 'icon-[lucide--star]',
label: 'First',
tooltip: 'First action',
onClick: () => {}
},
{
icon: 'icon-[lucide--heart]',
label: 'Second',
tooltip: 'Second action',
onClick: () => {}
}
]
})
})
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(
container.getByRole('button', { name: 'First action' })
).toBeVisible()
await expect(
container.getByRole('button', { name: 'Second action' })
).toBeVisible()
})
})
test.describe('Click handler', () => {
test('clicking a button fires its onClick handler', async ({
comfyPage
}) => {
const onClickFired = comfyPage.page.evaluate(
({ icon, label, tooltip }) =>
new Promise<boolean>((resolve) => {
window.app!.registerExtension({
name: 'TestActionBarButton',
actionBarButtons: [
{ icon, label, tooltip, onClick: () => resolve(true) }
]
})
}),
{ icon: ICON_CLASS, label: BUTTON_LABEL, tooltip: BUTTON_TOOLTIP }
)
const button = comfyPage.page
.getByTestId(TestIds.topbar.actionBarButtons)
.getByRole('button', { name: BUTTON_TOOLTIP })
await button.click()
await expect(onClickFired).resolves.toBe(true)
})
})
test.describe('Mobile layout', { tag: ['@mobile'] }, () => {
test('button label is hidden on mobile viewport', async ({ comfyPage }) => {
await registerTestButton(comfyPage.page)
const container = comfyPage.page.getByTestId(
TestIds.topbar.actionBarButtons
)
await expect(container).toBeVisible()
await expect(container.getByText(BUTTON_LABEL)).toBeHidden()
})
})
})

View File

@@ -2,7 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
test.describe('App mode arrange step', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -3,8 +3,8 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
const RESIZE_NODE_ID = '1'

View File

@@ -5,7 +5,7 @@ import {
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
} from '@e2e/helpers/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/fixtures/utils/clipboardSpy'
} from '@e2e/helpers/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)

View File

@@ -8,7 +8,7 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
} from '@e2e/helpers/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']

View File

@@ -8,8 +8,8 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
} from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
/**
* After a first save, open save-as again from the chevron,

View File

@@ -1,175 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
nodeBounds: Size,
padding: number,
titleHeight: number
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
const DRAG_DELTA = { x: 550, y: 330 } as const
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
})
await note.centerOnNode()
return note
}
test('shift+drag rounds final node position to multiples of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 100 = (600, 300)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(600)
expect(after[1]).toBe(300)
})
test('grid size determines the snap multiple', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 50)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 50 = (550, 350)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(550)
expect(after[1]).toBe(350)
})
test('drag without shift bypasses snap regardless of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
const before = await note.getProperty<[number, number]>('pos')
await note.dragBy(DRAG_DELTA)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(before[0] + DRAG_DELTA.x)
expect(after[1]).toBe(before[1] + DRAG_DELTA.y)
})
})
test.describe('Comfy.GroupSelectedNodes.Padding', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
)
await comfyPage.command.executeCommand('Comfy.Canvas.SelectAll')
await comfyPage.command.executeCommand('Comfy.Graph.GroupSelectedNodes')
return comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return { width: group.size[0], height: group.size[1] }
})
}
test('padding=0 makes the group exactly enclose the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 0)
expect(group).toEqual(expectedGroupSize(nodeBounds, 0, titleHeight))
})
test('padding=50 grows the group by 100 around the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 50)
expect(group).toEqual(expectedGroupSize(nodeBounds, 50, titleHeight))
})
})
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const fileCombo = await loadImage.getWidget(0)
await fileCombo.click()
}
test('combo widget popup is scaled when setting is enabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('LiteGraph.ContextMenu.Scaling', true)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS(
'transform',
`matrix(${ZOOM_SCALE}, 0, 0, ${ZOOM_SCALE}, 0, 0)`
)
})
test('combo widget popup is not scaled when setting is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.ContextMenu.Scaling',
false
)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS('transform', 'none')
})
})
})

View File

@@ -1,400 +0,0 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow is expected to contain exactly two CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const p1 = await clipNodes[0].getPosition()
const p2 = await clipNodes[1].getPosition()
const margin = 64
const from = await comfyPage.canvasOps.toAbsolute({
x: Math.min(p1.x, p2.x) - margin,
y: Math.min(p1.y, p2.y) - margin
})
const to = await comfyPage.canvasOps.toAbsolute({
x: Math.max(p1.x, p2.x) + margin,
y: Math.max(p1.y, p2.y) + margin
})
return { from, to }
}
test.describe('Canvas settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Graph.CanvasInfo', () => {
test(
'toggles the bottom-left HUD',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const box = await comfyPage.canvas.boundingBox()
expect(box, 'Canvas bounding box must be available').not.toBeNull()
// HUD is drawn ~80px tall along the bottom edge of the canvas; grab a
// comfortable 180px × 160px strip to catch it across viewports.
const HUD_WIDTH = 180
const HUD_HEIGHT = 160
const hudClip = {
x: box!.x,
y: box!.y + box!.height - HUD_HEIGHT,
width: HUD_WIDTH,
height: HUD_HEIGHT
}
await test.step('Capture HUD region with setting off', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
await comfyPage.canvasOps.resetView()
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
await test.step('Capture HUD region with setting on', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}
)
})
test.describe('Comfy.Graph.CtrlShiftZoom', () => {
const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 }
const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 }
test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.not.toBeCloseTo(initialScale, 2)
})
test('Ctrl+Shift+drag does not zoom when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2)
})
})
test.describe('Comfy.Graph.LiveSelection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
})
test('selects nodes mid-drag when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
})
test('defers selection to drag end when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
await comfyPage.page.mouse.up()
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
const WHEEL_POS = { x: 400, y: 400 }
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'zoom'
)
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo(
initialScale,
3
)
})
test('wheel pans when set to panning', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
)
const initialScale = await comfyPage.canvasOps.getScale()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(1)
})
})
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
test('override to panning makes empty left-drag pan the canvas', async ({
comfyPage
}) => {
await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
})
await comfyPage.canvasOps.resetView()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.canvasOps.dragAndDrop(
{ x: 200, y: 300 },
{ x: 400, y: 500 }
)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(50)
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
})
test('override to select turns empty left-drag into a selection rectangle', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.canvasOps.dragAndDrop(from, to)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Pointer settings', () => {
/**
* Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
const holdDragAt = async (
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) => {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
await sleep(opts.holdMs)
await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
}
test('DoubleClickTime controls whether two clicks open the title editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow must have CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const titlePos = await clipNodes[0].getTitlePosition()
const CLICK_GAP_MS = 200
await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
100
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectHidden()
})
await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
1000
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectVisible()
})
})
test('ClickBufferTime governs the click-vs-drag time threshold', async ({
comfyPage
}) => {
// Keep drift generous so only elapsed time distinguishes click vs drag.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 2
const HOLD_MS = 250
await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.ClickBufferTime',
2000
)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
test('ClickDrift governs the click-vs-drag distance threshold', async ({
comfyPage
}) => {
// Keep buffer generous so only drift distance matters.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 8
await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
})
test.describe('LiteGraph.Canvas.MaximumFps', () => {
// Behavioural FPS counting via rAF is not reliable under Playwright
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0)
await expect.poll(() => getFrameGap(comfyPage)).toBe(0)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -13,35 +12,25 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
// 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([])
},
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
stubCloudAssets: [
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
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)
await page.unroute(pattern)
},
{ auto: true }
]
@@ -53,36 +42,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
})
test('should use first cloud asset when server default is not in assets', async ({
cloudAssetRequests,
comfyPage
}) => {
// Wait for the checkpoint asset query to complete and the existing widget
// to upgrade into asset mode before creating a fresh node. The current
// default node may keep a previously resolved value; what matters is that
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
)
.toBe(true)
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
await expect
.poll(
() =>
comfyPage.page.evaluate((waitingForWidgetType) => {
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return (
node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type ?? waitingForWidgetType
)
}, WAITING_FOR_WIDGET_TYPE),
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
{ timeout: 10_000 }
)
.toBe('asset')
@@ -105,22 +81,15 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
await expect
.poll(
() =>
comfyPage.page.evaluate(
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return waitingForWidgetType
const val = String(widget?.value ?? '')
return val === 'Select model' ? waitingForWidgetValue : val
},
{
id: nodeId,
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
}
),
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)

View File

@@ -1,306 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
const PUBLISH_FEATURE_FLAGS = {
comfyhub_upload_enabled: true,
comfyhub_profile_gate_enabled: true
} as const
async function saveAndOpenPublishDialog(
comfyPage: ComfyPage,
dialog: PublishDialog,
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
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 {
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
await comfyPage.confirmDialog.click('overwrite')
} catch {
// No overwrite dialog — workflow name was unique.
}
await dialog.open()
}
test.describe('Publish dialog - wizard navigation', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
})
test('opens on the Describe step by default', async ({ publishDialog }) => {
await expect(publishDialog.describeStep).toBeVisible()
await expect(publishDialog.nameInput).toBeVisible()
await expect(publishDialog.descriptionTextarea).toBeVisible()
})
test('pre-fills workflow name from active workflow', async ({
publishDialog
}) => {
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
})
test('Next button navigates to Examples step', async ({ publishDialog }) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
// Examples step should show thumbnail toggle and upload area
await expect(
publishDialog.root.getByText('Select a thumbnail')
).toBeVisible()
})
test('Back button returns to Describe step from Examples', async ({
publishDialog
}) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
await publishDialog.goBack()
await expect(publishDialog.describeStep).toBeVisible()
})
test('navigates through all steps to Finish', async ({ publishDialog }) => {
await publishDialog.goNext() // → Examples
await publishDialog.goNext() // → Finish
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeVisible()
})
test('clicking nav step navigates directly', async ({ publishDialog }) => {
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.goToStep('Describe your workflow')
await expect(publishDialog.describeStep).toBeVisible()
})
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(publishDialog.root).toBeHidden()
})
})
test.describe('Publish dialog - Describe step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
})
test('allows editing the workflow name', async ({ publishDialog }) => {
await publishDialog.nameInput.clear()
await publishDialog.nameInput.fill('My Custom Workflow')
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
})
test('allows editing the description', async ({ publishDialog }) => {
await publishDialog.descriptionTextarea.fill(
'A great workflow for anime art'
)
await expect(publishDialog.descriptionTextarea).toHaveValue(
'A great workflow for anime art'
)
})
test('displays tag suggestions from mocked API', async ({
publishDialog
}) => {
await expect(publishDialog.root.getByText('anime')).toBeVisible()
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
})
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
// the active list during E2E. Needs investigation of the parent state
// binding.
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
await publishDialog.root.getByText('anime').click()
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
})
})
test.describe('Publish dialog - Examples step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
await publishDialog.goNext() // Navigate to Examples step
})
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByText('Image', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Video', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Image comparison', { exact: true })
).toBeVisible()
})
test('shows example image upload tile', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByRole('button', { name: 'Upload example image' })
).toBeVisible()
})
})
test.describe('Publish dialog - Finish step with profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
await publishDialog.goToStep('Finish publishing')
})
test('shows profile card with username', async ({ publishDialog }) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
})
test('publish button is enabled when no private assets', async ({
publishDialog
}) => {
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - Finish step with private assets', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({
hasProfile: true,
hasPrivateAssets: true
})
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
await publishDialog.goToStep('Finish publishing')
})
test('publish button is disabled until assets acknowledged', async ({
publishDialog
}) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeDisabled()
const checkbox = publishDialog.finishStep.getByRole('checkbox')
await checkbox.check()
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - no profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: false })
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-noprofile-wf'
)
await publishDialog.goToStep('Finish publishing')
})
test('shows profile creation prompt when user has no profile', async ({
publishDialog
}) => {
await expect(publishDialog.profilePrompt).toBeVisible()
await expect(
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
).toBeVisible()
})
test('clicking create profile CTA shows profile creation form', async ({
publishDialog
}) => {
await publishDialog.root
.getByRole('button', { name: 'Create a profile' })
.click()
await expect(publishDialog.gateFlow).toBeVisible()
})
})
test.describe('Publish dialog - unsaved workflow', () => {
test.beforeEach(async ({ comfyPage, publishApi }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
// Don't save workflow — open dialog on the default temporary workflow
})
test('shows save prompt for temporary workflow', async ({
comfyPage,
publishDialog
}) => {
// Create a new workflow to ensure it's temporary
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await publishDialog.open()
await expect(publishDialog.savePrompt).toBeVisible()
await expect(
publishDialog.root.getByText(
'You must save your workflow before publishing'
)
).toBeVisible()
// Nav should be hidden when save is required
await expect(publishDialog.nav).toBeHidden()
})
})
test.describe('Publish dialog - submission', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
})
test('successful publish closes dialog', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
})
test('failed publish shows error toast', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
// Override publish mock with error response
await publishApi.mockPublishWorkflowError(500, 'Internal error')
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-submit-fail-wf'
)
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
// Error toast should appear
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
timeout: 10_000
})
// Dialog should remain open
await expect(publishDialog.root).toBeVisible()
})
})

View File

@@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/fixtures/utils/clipboardSpy'
} from '@e2e/helpers/clipboardSpy'
async function triggerConfigureError(
comfyPage: ComfyPage,

View File

@@ -5,7 +5,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { cleanupFakeModel } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,208 +0,0 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
const VAE_DECODE_SAMPLES_INPUT_SLOT = 0
const DEFAULT_GROUP_TITLE = 'Group'
test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
test.describe('Comfy.LinkRelease.Action', () => {
test('"search box" opens node search on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
test('"context menu" opens litegraph connection menu on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'context menu'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
})
test('"no action" suppresses both search box and context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeHidden()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
})
})
test.describe('Comfy.LinkRelease.ActionShift', () => {
test('shift+drag dispatches to ActionShift (not Action)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.canvasOps.disconnectEdge({ modifiers: ['Shift'] })
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
})
test.describe('Comfy.Node.DoubleClickTitleToEdit', () => {
test('enabled → double-click on node title opens editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
await comfyPage.titleEditor.expectVisible()
})
test('disabled → double-click on node title stays hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
false
)
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
await comfyPage.titleEditor.expectHidden()
})
})
test.describe('Comfy.Group.DoubleClickTitleToEdit', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
})
test('enabled → double-click on group title opens editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Group.DoubleClickTitleToEdit',
true
)
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
await comfyPage.titleEditor.expectVisible()
})
test('disabled → double-click on group title stays hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Group.DoubleClickTitleToEdit',
false
)
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
await comfyPage.titleEditor.expectHidden()
})
})
test.describe('Comfy.Node.BypassAllLinksOnDelete', () => {
test('enabled → deleting KSampler bridges EmptyLatentImage → VAEDecode.samples', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.BypassAllLinksOnDelete',
true
)
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const [emptyLatent] =
await comfyPage.nodeOps.getNodeRefsByType('EmptyLatentImage')
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
const vaeSamplesInput = await vaeDecode.getInput(
VAE_DECODE_SAMPLES_INPUT_SLOT
)
await test.step('precondition: KSampler feeds VAEDecode.samples', async () => {
expect(
(await vaeSamplesInput.getLink())?.origin_id,
'VAEDecode.samples should originate from KSampler before delete'
).toBe(kSampler.id)
})
await kSampler.delete()
await expect
.poll(async () => (await vaeSamplesInput.getLink())?.origin_id ?? null)
.toBe(emptyLatent.id)
})
test('disabled → deleting KSampler drops VAEDecode.samples', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.BypassAllLinksOnDelete',
false
)
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
const vaeSamplesInput = await vaeDecode.getInput(
VAE_DECODE_SAMPLES_INPUT_SLOT
)
await kSampler.delete()
await expect.poll(() => vaeSamplesInput.getLink()).toBeNull()
})
})
test.describe('Comfy.Node.MiddleClickRerouteNode', () => {
async function countReroutes(comfyPage: ComfyPage): Promise<number> {
return (await comfyPage.nodeOps.getNodeRefsByType('Reroute')).length
}
test('enabled → middle-click on an output slot creates a Reroute', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
true
)
const before = await countReroutes(comfyPage)
await comfyPage.canvasOps.middleClick(
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
)
await expect.poll(() => countReroutes(comfyPage)).toBe(before + 1)
})
test('disabled → middle-click on an output slot does nothing', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.MiddleClickRerouteNode',
false
)
const before = await countReroutes(comfyPage)
await comfyPage.canvasOps.middleClick(
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
)
await comfyPage.nextFrame()
expect(await countReroutes(comfyPage)).toBe(before)
})
})
})

View File

@@ -282,57 +282,6 @@ test.describe('Load3D', () => {
})
})
test.describe('Load3D silent 404 on missing output model', () => {
test('Does not show an error toast when the output model file is missing (404)', async ({
comfyPage
}) => {
// Intercept model fetch and return 404 to simulate a missing output file
// (e.g. shared workflow opened on a machine that never ran it)
await comfyPage.page.route('**/view?**', (route) =>
route.fulfill({ status: 404, body: 'Not Found' })
)
// This workflow has a Preview3D node with Last Time Model File set,
// triggering the loadFolder: 'output' + silentOnNotFound: true path.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
// Wait for the 404 response before asserting — gives the load attempt time
// to complete without using waitForTimeout
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
await responsePromise
await expect(
comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' })
).toHaveCount(0)
})
test('Shows an error toast when a non-404 error occurs loading the output model', async ({
comfyPage
}) => {
// Intercept with a 500 to simulate a real server error (not 404) — toast must appear
await comfyPage.page.route('**/view?**', (route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
await responsePromise
await expect
.poll(
() =>
comfyPage.toast.visibleToasts
.filter({ hasText: 'Error loading model' })
.count(),
{ timeout: 10000 }
)
.toBeGreaterThan(0)
})
})
test.describe('Load3D initialization failure', () => {
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
comfyPage

View File

@@ -1,106 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Enable the show_signin_button server feature flag so LoginButton renders
* in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`).
* The flag is reset automatically on each fresh page load in beforeEach.
*/
async function enableLoginButtonFlag(page: Page): Promise<void> {
await page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: true
}
})
}
test.describe('Login Button', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
test.describe('Visibility', () => {
test('button is hidden when show_signin_button flag is off', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: false
}
})
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeHidden()
})
test('button is visible when show_signin_button flag is enabled', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeVisible()
})
})
test.describe('ARIA', () => {
test('button has correct aria-label', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await expect(button).toHaveAttribute('aria-label', /.+/)
})
})
test.describe('Click behaviour', () => {
test('clicking the button opens the sign-in dialog', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const dialog = new SignInDialog(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click()
await expect(dialog.root).toBeVisible()
})
})
test.describe('Hover popover', () => {
test('hovering shows an informational popover', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
})
test('popover contains a Learn more link', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
const learnMoreLink = comfyPage.page.getByTestId(
TestIds.topbar.loginButtonPopoverLearnMore
)
await expect(learnMoreLink).toBeVisible()
await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/)
})
test('popover hides after mouse leaves the button area', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await button.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
await comfyPage.canvas.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeHidden()
})
})
})

View File

@@ -22,14 +22,18 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId: nodeRef.id, centerX, centerY }
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -76,6 +80,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -148,184 +153,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
test(
'subgraph blueprint added from search box enters ghost mode',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
// Convert a node to a subgraph and publish it as a blueprint
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
expect(subgraphNodes).toHaveLength(1)
const subgraphNode = subgraphNodes[0]
const blueprintName = `ghost-test-${Date.now()}`
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
// Open v2 search box and search for the published blueprint
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill(blueprintName)
await expect(searchBoxV2.results.first()).toBeVisible()
// Click the result to add the node (v2 search box uses ghost mode)
await searchBoxV2.results.first().click()
await comfyPage.nextFrame()
// A new node should exist on the graph in ghost mode
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
const ghostNodeId = await comfyPage.page.evaluate(() => {
return window.app!.canvas.state.ghostNodeId
})
expect(ghostNodeId).not.toBeNull()
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
expect(ghostState).not.toBeNull()
expect(ghostState!.ghost).toBe(true)
// Wait for search box to close, then click to confirm placement
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page.mouse.click(
Math.round(viewport.width / 2),
Math.round(viewport.height / 2)
)
await comfyPage.nextFrame()
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
expect(afterPlace).not.toBeNull()
expect(afterPlace!.ghost).toBe(false)
}
)
test(
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
true
)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
if (mode === 'vue') {
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
} else {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
}
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const subgraphId = await comfyPage.subgraph.getActiveGraphId()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.app!.canvas.state.ghostNodeId != null
)
)
.toBe(true)
await comfyPage.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
})
.toBe(true)
await expect
.poll(() => comfyPage.subgraph.getActiveGraphId())
.toBe(subgraphId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
)
.toBeNull()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount)
}
)
})
}

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'

View File

@@ -5,19 +5,32 @@ import {
test.describe('Node search box V2', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
@@ -27,28 +40,33 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
// Default results should be visible without typing.
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Default results should be visible without typing
await expect(searchBoxV2.results.first()).toBeVisible()
// Enter should add the first (selected) result
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test.describe('Category navigation', () => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.reload(comfyPage)
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('favorites').click()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -59,10 +77,13 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
})
@@ -70,23 +91,26 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await test.step('Open Input filter popover', async () => {
await searchBoxV2.typeFilterButton('input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
})
// Click "Input" filter chip in the filter bar
await searchBoxV2.filterBarButton('Input').click()
await test.step('Select MODEL type', async () => {
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
})
// Filter options should appear
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Filter chip should appear and results should be filtered
await expect(
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
).toContainText('MODEL')
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
@@ -96,180 +120,32 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await test.step('First result is selected by default', async () => {
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// First result selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('ArrowDown moves selection to next result', async () => {
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
})
// ArrowDown moves selection
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
await test.step('ArrowUp moves selection back', async () => {
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// ArrowUp moves back
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('Enter selects and adds the node', async () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})
// Enter selects and adds node
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
test.describe('Category sidebar', () => {
test('Sidebar toggle hides and shows the category sidebar', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingCategory = searchBoxV2.categoryButton('sampling')
await expect(samplingCategory).toBeVisible()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(samplingCategory).toBeHidden()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await expect(samplingCategory).toBeVisible()
})
test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
// Narrow viewport so the chips overflow the filter bar
await comfyPage.page.setViewportSize({ width: 360, height: 800 })
await searchBoxV2.open()
const scrollEl = searchBoxV2.filterChipsScroll
const dims = await scrollEl.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth
}))
expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth)
await scrollEl.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// The toggle lives outside the scroll container, so even when the
// chips scroll hundreds of px it must remain visible in the viewport.
await expect(searchBoxV2.sidebarToggle).toBeInViewport()
})
test('@mobile Sidebar is collapsed by default on mobile', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
})
test('@mobile Clicking outside the sidebar closes it', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeVisible()
await expect(searchBoxV2.sidebarBackdrop).toBeVisible()
// The backdrop spans the full content area, but the sidebar (z-20)
// covers its left ~208px (w-52). Click past that to land on the
// backdrop rather than the sidebar.
await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } })
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
await expect(searchBoxV2.sidebarBackdrop).toBeHidden()
})
test('@mobile Focusing the search input closes the sidebar', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.sidebarToggle.click()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'true'
)
await searchBoxV2.input.focus()
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
'false'
)
})
test('Sidebar state across mobile/desktop resizes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const switchToDesktop = () =>
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
const switchToMobile = () =>
comfyPage.page.setViewportSize({ width: 360, height: 800 })
const expectExpanded = (value: 'true' | 'false') =>
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
value
)
await switchToDesktop()
await searchBoxV2.open()
await expectExpanded('true')
await switchToMobile()
await expectExpanded('false')
await searchBoxV2.sidebarToggle.click()
await switchToDesktop()
await expectExpanded('true')
await searchBoxV2.sidebarToggle.click()
await switchToMobile()
await expectExpanded('false')
await switchToDesktop()
await expectExpanded('false')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})

View File

@@ -2,17 +2,27 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.openByDoubleClickCanvas()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.dialog).toBeVisible()
})
@@ -22,40 +32,43 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
for (const closeKey of ['Enter', 'Escape'] as const) {
test(`Reopening search after ${closeKey} has no persisted state`, async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press(closeKey)
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
}
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
})
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -63,6 +76,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(samplingResults)
@@ -73,328 +87,58 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search first to keep the result set under the 64-item cap.
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await test.step('Apply Input/MODEL filter', async () => {
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
await test.step('Remove the filter chip', async () => {
await searchBoxV2.removeFilterChip()
await expect(searchBoxV2.filterChips).toHaveCount(0)
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
})
})
})
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
// Record initial result text for comparison
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).toBeHidden()
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const NODE_TYPE = 'CLIPTextEncode'
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const idsBefore = new Set(refsBefore.map((n) => n.id))
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('CLIP Text Encode')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
// A new CLIPTextEncode node should have been added.
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByType(NODE_TYPE)
.then((refs) => refs.length)
)
.toBe(refsBefore.length + 1)
// Verify the auto-connect: the newly-added node's CLIP input must be
// connected (proves the release wasn't just dropped).
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
const clipInput = await newNode!.getInput(0)
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.results.first()).toBeVisible()
const singleFilterCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Sampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(unfilteredCount)
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const comfyCount = await searchBoxV2.results.count()
// Under root filter, categories are prefixed (e.g. comfy/sampling).
await searchBoxV2.categoryButton('comfy/sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingBtn = searchBoxV2.categoryButton('sampling')
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await test.step('Expanding sampling reveals its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeVisible()
})
await test.step('Collapsing sampling hides its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeHidden()
})
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const parentCount = await searchBoxV2.results.count()
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.results.first()).toBeVisible()
const defaultCount = await searchBoxV2.results.count()
await searchBoxV2.categoryButton('sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(defaultCount)
await searchBoxV2.categoryButton('most-relevant').click()
await expect(searchBoxV2.results).toHaveCount(defaultCount)
})
test(
'Blueprint root chip filters to published blueprints',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByTitle('New Subgraph')
.then((refs) => refs.length)
)
.toBe(1)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
await subgraphNodes[0].click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const blueprintsChip = searchBoxV2.rootCategoryButton(
RootCategory.Blueprint
)
await expect(blueprintsChip).toBeVisible()
await blueprintsChip.click()
// Blueprints persist across tests on the same worker; filter by the
// unique name we just published rather than asserting the full list.
await expect(
searchBoxV2.results.filter({ hasText: blueprintName })
).toHaveCount(1)
}
)
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
await expect(searchBoxV2.results.first()).toBeVisible()
const count1 = await getCount()
await searchBoxV2.input.fill('Sa')
await expect.poll(getCount).toBeLessThan(count1)
const count2 = await getCount()
await searchBoxV2.input.fill('Sampler')
await expect.poll(getCount).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const chipTexts = await searchBoxV2.filterChips.allTextContents()
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
})
test('Follow-cursor disabled places node without ghost mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
false
)
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).toBeHidden()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await expect(
comfyPage.page.locator('[data-node-id][data-ghost]')
).toHaveCount(0)
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
})
})

View File

@@ -7,7 +7,7 @@ import {
drawStroke,
hasCanvasContent,
triggerSerialization
} from '@e2e/fixtures/utils/painter'
} from '@e2e/helpers/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transform1)
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
await comfyPage.page.mouse.move(0, 0)
await expect(cursor).toBeHidden()
})
@@ -187,10 +187,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.move(
box.x + box.width + 20,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
@@ -411,7 +408,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
// default 512 + slider step 64 = 576
.toBe(576)
})
@@ -497,29 +493,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBe(false)
}
)
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should start empty'
})
.toBe(false)
await painterWidget
.getByTestId('painter-clear-button')
.dispatchEvent('click')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should still be empty after clearing empty canvas'
})
.toBe(false)
})
})
test.describe('Serialization', () => {
@@ -587,6 +560,36 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
@@ -601,318 +604,18 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test.describe('Serialization — unchanged canvas', () => {
test(
'Unchanged canvas does not re-upload on second serialization',
{ tag: '@slow' },
async ({ comfyPage }) => {
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockResponse)
})
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'first serialization should upload once').toBe(1)
await triggerSerialization(comfyPage.page)
expect(
uploadCount,
'second serialization without new drawing should not re-upload'
).toBe(1)
}
)
})
test.describe('Settings persistence', () => {
test('Tool selection is saved to node properties', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
| string
| undefined
}),
{ message: 'painterTool property should update to eraser' }
)
.toBe('eraser')
})
test('Brush size change is saved to node properties', async ({
comfyPage
}) => {
const sizeRow = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
.getByTestId('painter-size-row')
const sizeSlider = sizeRow.getByRole('slider')
await expect(
sizeRow.getByTestId('painter-size-value'),
'brush size should start at default 20'
).toHaveText('20')
await sizeSlider.focus()
for (let i = 0; i < 10; i++) {
await sizeSlider.press('ArrowRight')
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties
?.painterBrushSize as number | undefined
}),
{ message: 'painterBrushSize property should update to 30' }
)
.toBe(30)
})
})
test('Controls collapse to single column in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
).toBeVisible()
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
if (node) {
node.size = [200, 400]
window.app!.canvas.setDirty(true, true)
}
})
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({
comfyPage
}) => {
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
}, yFraction)
await expect
.poll(() => hasContentAtRow(0.25), {
message: 'top stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.5), {
message: 'middle stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.75), {
message: 'bottom stroke should be present'
})
.toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
})
})
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },
() => {
test.setTimeout(60_000)
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
})
test('Width, height, and bg_color controls hide when input is connected', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(
painterWidget.getByTestId('painter-width-row'),
'width row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-height-row'),
'height row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-bg-color-row'),
'background color row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-dimension-text'),
'dimension text should be visible when input is connected'
).toBeVisible()
})
test('Canvas resizes to match input image dimensions after execution', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{
message: 'input image should be fully decoded',
timeout: 30_000
}
)
.toBe(true)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas width should match input image natural width'
})
.toBe(nw)
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
message: 'canvas height should match input image natural height'
})
.toBe(nh)
})
test('Drawing over input image produces content on canvas', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{ message: 'input image should be fully decoded', timeout: 30_000 }
)
.toBe(true)
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas should resize to match input image width',
timeout: 15_000
})
.toBe(nw)
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
// intercepts coordinate-based hit testing from page.mouse
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
const startX = box.x + box.width * 0.3
const endX = box.x + box.width * 0.7
const midY = box.y + box.height * 0.5
const pointerOpts = {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
isPrimary: true
}
await canvas.dispatchEvent('pointerdown', {
...pointerOpts,
clientX: startX,
clientY: midY
})
for (let i = 1; i <= 10; i++) {
await canvas.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + (endX - startX) * (i / 10),
clientY: midY
})
}
await canvas.dispatchEvent('pointerup', {
...pointerOpts,
clientX: endX,
clientY: midY
})
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'drawing over input image should produce canvas content'
})
.toBe(true)
})
}
)

View File

@@ -1,10 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
logMeasurement,
recordMeasurement
} from '@e2e/fixtures/utils/perfReporter'
import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {

View File

@@ -4,21 +4,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
export async function enableErrorsOverlay(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
}
/** Dismiss the error overlay (the floating dialog with the dismiss button). */
export async function dismissErrorOverlay(comfyPage: ComfyPage): Promise<void> {
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).toBeHidden()
}
export async function loadWorkflowAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: string

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
export class PropertiesPanelHelper {
@@ -9,14 +8,12 @@ export class PropertiesPanelHelper {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly closeButton: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.closeButton = this.root.locator('button[aria-pressed]')
this.titleEditor = new TitleEditor(this.root)
}
get tabs(): Locator {
@@ -31,6 +28,10 @@ export class PropertiesPanelHelper {
return this.panelTitle.locator('i[class*="lucide--pencil"]')
}
get titleInput(): Locator {
return this.root.getByTestId(TestIds.node.titleInput)
}
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
return this.root.locator('button', { hasText: state })
}
@@ -85,8 +86,8 @@ export class PropertiesPanelHelper {
async editTitle(newTitle: string): Promise<void> {
await this.titleEditIcon.click()
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(newTitle)
await this.titleInput.fill(newTitle)
await this.titleInput.press('Enter')
}
async searchWidgets(query: string): Promise<void> {

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(

View File

@@ -5,11 +5,11 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/fixtures/utils/clipboardSpy'
} from '@e2e/helpers/clipboardSpy'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -99,58 +99,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
)
await expect(downloadButton.first()).toBeVisible()
})
test('Should render Download all and Refresh actions for one downloadable model', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
).toBeVisible()
})
test('Should clear resolved missing model when Refresh is clicked', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await comfyPage.page.route(/\/object_info$/, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})
})
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -6,7 +6,7 @@ import {
cleanupFakeModel,
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -18,7 +18,7 @@ test.describe('Properties panel - Title editing', () => {
test('should enter edit mode on pencil click', async () => {
await panel.titleEditIcon.click()
await panel.titleEditor.expectVisible()
await expect(panel.titleInput).toBeVisible()
})
test('should update node title on edit', async () => {

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