Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Brown
c2c0e217c0 Merge branch 'main' into fix/error-toast 2026-04-28 19:17:28 -07:00
bymyself
e02776c793 fix: only show preload error toast for stale chunk errors
Add isStaleChunkError() filter that checks for hashed JS/CSS/MJS assets
under /assets/ before showing the toast. Non-asset URLs (e.g. /api/i18n)
and general network errors no longer trigger the toast. Logging and
Sentry reporting remain unconditional.
2026-03-14 22:56:04 -07:00
207 changed files with 4050 additions and 13216 deletions

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,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,20 +16,8 @@ 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:

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
@@ -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

@@ -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:

View File

@@ -3,29 +3,25 @@ name: 'CI: Website E2E'
on:
push:
branches: [main]
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 }}
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

@@ -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,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

@@ -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

@@ -3298,13 +3298,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

@@ -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

@@ -96,17 +96,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:

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

@@ -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,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

@@ -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

@@ -17,9 +17,6 @@ export class ComfyNodeSearchBoxV2 {
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
@@ -31,11 +28,6 @@ export class ComfyNodeSearchBoxV2 {
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
)
}
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */

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,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

@@ -210,8 +210,7 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list'
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
@@ -262,9 +261,6 @@ export const TestIds = {
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}`

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

@@ -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

@@ -267,65 +267,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
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

@@ -125,151 +125,4 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})
})
})
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')
})
})
})

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

@@ -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

@@ -9,7 +9,7 @@ import {
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 }) => {

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

@@ -1,121 +0,0 @@
import { mergeTests } from '@playwright/test'
import type { Locator, Page, Request } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const TOTAL_MOCK_JOBS = 20
const overflowJobsListRoutePattern = '**/api/jobs?*'
function isHistoryJobsRequest(url: string): boolean {
if (!url.includes('/api/jobs')) return false
const params = new URL(url).searchParams
const statuses = (params.get('status') ?? '').split(',')
return statuses.includes('completed')
}
async function captureNextHistoryRequest(
comfyPage: ComfyPage,
exec: ExecutionHelper
): Promise<Request> {
const requestPromise = comfyPage.page.waitForRequest(
(req) => isHistoryJobsRequest(req.url()),
{ timeout: 5000 }
)
exec.status(0)
return requestPromise
}
function getJobListResults(page: Page): Locator {
return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]')
}
test.describe('Queue settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Queue.MaxHistoryItems', () => {
test.describe('limit query parameter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(
createMockJobs(TOTAL_MOCK_JOBS)
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('limit query parameter on /api/jobs reflects the setting', async ({
comfyPage,
getWebSocket
}) => {
const TARGET_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
TARGET_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
const request = await captureNextHistoryRequest(comfyPage, exec)
const url = new URL(request.url())
expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT))
})
})
test('queue panel caps history items to the configured number', async ({
comfyPage,
getWebSocket
}) => {
// Add a mock route that returns all jobs regardless of the request's `limit` param
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await comfyPage.page.route(
overflowJobsListRoutePattern,
async (route) => {
const url = new URL(route.request().url())
if (!url.searchParams.get('status')?.includes('completed')) {
await route.continue()
return
}
const response = {
jobs: overflowJobs,
pagination: {
offset: 0,
limit: overflowJobs.length,
total: overflowJobs.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
)
const VISIBLE_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
VISIBLE_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
await captureNextHistoryRequest(comfyPage, exec)
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
const jobs = getJobListResults(comfyPage.page)
await expect(jobs.first()).toBeVisible()
await expect(jobs).toHaveCount(VISIBLE_LIMIT)
})
})
})

View File

@@ -42,11 +42,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
// Selection toolbox should be visible with multiple nodes selected
await expect(comfyPage.selectionToolbox).toBeVisible()
// Border is now drawn on canvas, check via screenshot
// Allow small anti-aliasing variance on the canvas-drawn selection border
// (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35)
await expect(comfyPage.canvas).toHaveScreenshot(
'selection-toolbox-multiple-nodes-border.png',
{ maxDiffPixels: 100 }
'selection-toolbox-multiple-nodes-border.png'
)
})

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 { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -14,6 +14,8 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -40,6 +42,31 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
expect(results).toEqual(widgets.map(() => true))
}
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
@@ -498,4 +525,29 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -121,7 +121,10 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
})
test('should allow fitting group to contents', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -1,25 +1,9 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
enableErrorsOverlay
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -27,202 +11,24 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID)
).toHaveClass(ERROR_CLASS)
// Expect error state on missing unknown node
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
test('should display error state when node causes execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
const raiseErrorId =
await comfyPage.vueNodes.getNodeIdByTitle('Raise Error')
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(raiseErrorId)
).toHaveClass(ERROR_CLASS)
})
test.describe('validation errors', () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
test('shows error ring when a validation error is returned for a node', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
})
test('clears error ring when user edits an out-of-range number widget back into range', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
const exec = new ExecutionHelper(comfyPage)
await test.step('queue with out-of-range steps to surface the error', async () => {
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('edit steps widget so the new value is within range', async () => {
const stepsWidget = comfyPage.vueNodes.getWidgetByName(
'KSampler',
'steps'
)
const controls = comfyPage.vueNodes.getInputNumberControls(stepsWidget)
// ScrubableNumberInput commits on blur — explicit blur avoids a race
// with the keyup-Enter handler in case Enter is consumed elsewhere.
await controls.input.fill('25')
await controls.input.blur()
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user picks a different combo option', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
const exec = new ExecutionHelper(comfyPage)
await test.step('queue with invalid sampler to surface the error', async () => {
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_not_in_list',
'sampler_name',
'sampler_name: bogus_sampler is not in list'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('select a different sampler option', async () => {
await comfyPage.vueNodes.selectComboOption(
'KSampler',
'sampler_name',
'dpmpp_2m'
)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await cleanupFakeModel(comfyPage)
})
test('parent subgraph node shows error ring when an interior node is missing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
'Subgraph with Missing Node'
)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when an interior node has a missing model', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
'Subgraph with Missing Model'
)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when an interior node fails execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
await expect(
innerWrapper,
'subgraph parent must mount before injecting WS execution_error'
).toBeVisible()
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
exec.executionError(
'mocked-prompt',
INNER_EXECUTION_ID,
'boom inside the subgraph'
)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when interior node has a validation error', async ({
comfyPage
}) => {
// Validation errors are keyed by execution id, so an interior error
// ("2:1") must propagate the ring up to the root-level subgraph
// container ("2") via errorAncestorExecutionIds.
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
await expect(innerWrapper).toBeVisible()
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,10 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -24,11 +20,15 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
})
function getNode(comfyPage: ComfyPage) {
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(comfyPage: ComfyPage) {
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
@@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
// "Show advanced inputs" button should be present
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
@@ -58,41 +58,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
const vueNode =
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
await expect(showAdvancedButton).toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeHidden()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeVisible()
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
@@ -113,6 +92,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
await expect(node.getByText('Show advanced inputs')).toBeHidden()
})
})

View File

@@ -1,50 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = () =>
comfyPage.page.evaluate(
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
)
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1)
await legacyWidget.click({ position: { x: width - 20, y: height / 2 } })
await expect.poll(() => legacyWidgetRef.getValue()).toBe(0)
})
await test.step('Resize to update width', async () => {
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')
await expect(gutter).toBeVisible()
await comfyMouse.resizeByDragging(gutter, { x: -200 })
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
const intermediateWidth = await getWidth()
await comfyMouse.resizeByDragging(gutter, { x: 100 })
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
})
})

View File

@@ -1,185 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { WidgetBoundingBoxFixture } from '@e2e/fixtures/components/WidgetBoundingBox'
const NODE_ID = '1'
test.describe('Widget Bounding Box', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
})
test(
'Renders all four coordinate inputs with workflow values',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await expect(boundingBox.root).toBeVisible()
await expect(boundingBox.x.input).toHaveValue('0')
await expect(boundingBox.y.input).toHaveValue('0')
await expect(boundingBox.width.input).toHaveValue('512')
await expect(boundingBox.height.input).toHaveValue('512')
}
)
test('Typing into each coordinate updates only that coordinate', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('type X', async () => {
await boundingBox.x.type(25)
await expect(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('0')
await expect.soft(boundingBox.width.input).toHaveValue('512')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Y', async () => {
await boundingBox.y.type(40)
await expect(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.width.input).toHaveValue('512')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Width', async () => {
await boundingBox.width.type(200)
await expect(boundingBox.width.input).toHaveValue('200')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Height', async () => {
await boundingBox.height.type(300)
await expect(boundingBox.height.input).toHaveValue('300')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.width.input).toHaveValue('200')
})
})
test('Negative X/Y values are clamped to min=0', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.x.type(50)
await expect(boundingBox.x.input).toHaveValue('50')
await boundingBox.x.type('-10')
await expect(boundingBox.x.input).toHaveValue('0')
await boundingBox.y.type(75)
await expect(boundingBox.y.input).toHaveValue('75')
await boundingBox.y.type('-50')
await expect(boundingBox.y.input).toHaveValue('0')
})
test('Width/Height values below 1 are clamped to min=1', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.width.type(0)
await expect(boundingBox.width.input).toHaveValue('1')
await boundingBox.height.type('-5')
await expect(boundingBox.height.input).toHaveValue('1')
})
test('Increment and decrement buttons change coordinate by step', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('increment X from 0 to 2', async () => {
await boundingBox.x.increment()
await boundingBox.x.increment()
await expect(boundingBox.x.input).toHaveValue('2')
})
await test.step('decrement X from 2 to 1', async () => {
await boundingBox.x.decrement()
await expect(boundingBox.x.input).toHaveValue('1')
})
await test.step('decrement Width from 512 to 510', async () => {
await boundingBox.width.decrement()
await boundingBox.width.decrement()
await expect(boundingBox.width.input).toHaveValue('510')
})
await test.step('increment Height from 512 to 513', async () => {
await boundingBox.height.increment()
await expect(boundingBox.height.input).toHaveValue('513')
})
})
test('Arrow keys step the focused input; PageUp/PageDown step by 10', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.width.focus()
await boundingBox.width.input.press('ArrowUp')
await expect(boundingBox.width.input).toHaveValue('513')
await boundingBox.width.input.press('ArrowDown')
await boundingBox.width.input.press('ArrowDown')
await expect(boundingBox.width.input).toHaveValue('511')
await boundingBox.width.input.press('PageUp')
await expect(boundingBox.width.input).toHaveValue('521')
await boundingBox.width.input.press('PageDown')
await expect(boundingBox.width.input).toHaveValue('511')
})
test('Decrement button is disabled when value equals min', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('X at 0 disables decrement', async () => {
await expect(boundingBox.x.input).toHaveValue('0')
await expect(boundingBox.x.decrementButton).toBeDisabled()
await expect(boundingBox.x.incrementButton).toBeEnabled()
})
await test.step('Width at 1 disables decrement', async () => {
await boundingBox.width.type(1)
await expect(boundingBox.width.input).toHaveValue('1')
await expect(boundingBox.width.decrementButton).toBeDisabled()
await expect(boundingBox.width.incrementButton).toBeEnabled()
})
await test.step('Incrementing X re-enables decrement', async () => {
await boundingBox.x.increment()
await expect(boundingBox.x.decrementButton).toBeEnabled()
})
})
test('Non-numeric input reverts to previous value on blur', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.x.type(42)
await expect(boundingBox.x.input).toHaveValue('42')
await boundingBox.x.input.fill('not a number')
await boundingBox.x.input.blur()
await expect(boundingBox.x.input).toHaveValue('42')
})
})

View File

@@ -1,205 +0,0 @@
import type { Page, Request } from '@playwright/test'
import type {
ComfyApiWorkflow,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
function isUserdataWorkflowSave(request: Request): boolean {
return (
request.method() === 'POST' &&
/\/api\/userdata\/workflows%2F[^?]+\.json/.test(request.url())
)
}
function collectSaves(page: Page): Disposable & { readonly saves: string[] } {
const saves: string[] = []
function onRequest(request: Request) {
if (isUserdataWorkflowSave(request)) saves.push(request.url())
}
page.on('request', onRequest)
return {
saves,
[Symbol.dispose]() {
page.off('request', onRequest)
}
}
}
async function waitForSave(page: Page, timeout: number): Promise<boolean> {
return page
.waitForRequest(isUserdataWorkflowSave, { timeout })
.then(() => true)
.catch(() => false)
}
/**
* Drag the first node so the change tracker dispatches `graphChanged`.
*/
async function triggerGraphChange(comfyPage: ComfyPage): Promise<void> {
const node = await comfyPage.nodeOps.getFirstNodeRef()
if (!node) throw new Error('Default workflow expected to have a first node')
const titlePos = await node.getTitlePosition()
const absFrom = await comfyPage.canvasOps.toAbsolute(titlePos)
const absTo = { x: absFrom.x + 120, y: absFrom.y + 120 }
await comfyPage.canvasOps.dragAndDrop(absFrom, absTo)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
}
async function setupAutoSaveAfterDelay(
comfyPage: ComfyPage,
delayMs: number
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow('autosave')
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', delayMs)
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'after delay')
}
test.describe('Workflow settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Workflow.AutoSave', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
})
test("'off' does not save modified workflow after delay", async ({
comfyPage
}) => {
await comfyPage.menu.topbar.saveWorkflow('autosave')
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', 50)
await triggerGraphChange(comfyPage)
// Within a window an order of magnitude longer than AutoSaveDelay, the
// off watcher must not write back.
const sawSave = await waitForSave(comfyPage.page, 500)
expect(
sawSave,
'AutoSave=off must not write back after a graph change'
).toBe(false)
})
test("'after delay' saves the workflow after a graph change", async ({
comfyPage
}) => {
await setupAutoSaveAfterDelay(comfyPage, 100)
const savePromise = comfyPage.page.waitForRequest(
isUserdataWorkflowSave,
{ timeout: 4000 }
)
await triggerGraphChange(comfyPage)
await savePromise
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
})
})
test.describe('Comfy.Workflow.AutoSaveDelay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
})
test('long delay defers save until at least the configured duration has elapsed', async ({
comfyPage
}) => {
const LONG_DELAY_MS = 1000
const EARLY_WINDOW_MS = 500
await setupAutoSaveAfterDelay(comfyPage, LONG_DELAY_MS)
using tracker = collectSaves(comfyPage.page)
await triggerGraphChange(comfyPage)
// No save fires within a window comfortably shorter than the delay.
const sawEarlySave = await waitForSave(comfyPage.page, EARLY_WINDOW_MS)
expect(
sawEarlySave,
`No save should fire within ${EARLY_WINDOW_MS}ms when the configured delay is ${LONG_DELAY_MS}ms`
).toBe(false)
// Eventually the save does fire.
await comfyPage.page.waitForRequest(isUserdataWorkflowSave, {
timeout: 3000
})
expect(tracker.saves).toHaveLength(1)
})
})
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
async function getSerializedNodeIds(
comfyPage: ComfyPage
): Promise<NodeId[]> {
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
(n) => n.id
)
}
function ascendingById(ids: NodeId[]): NodeId[] {
return [...ids].sort((a, b) => Number(a) - Number(b))
}
test('false preserves the graph insertion order', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
false
)
const ids = await getSerializedNodeIds(comfyPage)
expect(ids, 'default workflow nodes already sorted').not.toEqual(
ascendingById(ids)
)
})
test('true sorts nodes by id ascending', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
true
)
const ids = await getSerializedNodeIds(comfyPage)
expect(ids).toEqual(ascendingById(ids))
})
test('toggling sort preserves node set in both workflow JSON and API prompt', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
false
)
const expectedIds = ascendingById(await getSerializedNodeIds(comfyPage))
await comfyPage.settings.setSetting(
'Comfy.Workflow.SortNodeIdOnSave',
true
)
// Workflow JSON nodes (the surface controlled by SortNodeIdOnSave) must
// still contain the same set of ids — sort changes order, not membership.
expect(ascendingById(await getSerializedNodeIds(comfyPage))).toEqual(
expectedIds
)
// The API prompt is independently derived from execution order, but it
// must enumerate the same node set regardless of the sort flag.
const apiPrompt: ComfyApiWorkflow =
await comfyPage.workflow.getExportedWorkflow({ api: true })
expect(ascendingById(Object.keys(apiPrompt).map(Number))).toEqual(
expectedIds
)
})
})
})

View File

@@ -257,8 +257,6 @@ it('should validate node definition', () => {
## Mocking Composables with Reactive State
> **Don't mock `vue-i18n`.** Mount with a real `createI18n` plugin instance instead — see [Don't Mock `vue-i18n` in `vitest-patterns.md`](./vitest-patterns.md#dont-mock-vue-i18n--use-a-real-plugin). This section applies to composables you own.
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules

View File

@@ -30,42 +30,9 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## Don't Mock `vue-i18n` — Use a Real Plugin
## i18n in Component Tests
Mount with a real `createI18n` instance instead of mocking `vue-i18n`. The plugin is cheap, owned by a third party (don't mock what you don't own), and a real instance exercises the same translation key resolution and pluralization logic that production uses.
This applies to **all tests** that touch a component or composable calling `useI18n()` — not just component tests.
```typescript
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} } // empty — assertions key off the translation key, not the rendered string
})
// Component tests: pass via global plugins
mount(MyComponent, { global: { plugins: [i18n] } })
// Composable tests: provide via a host component (see useMediaAssetActions.test.ts pattern)
const app = createApp(HostComponent)
app.use(i18n)
```
Real example: [`src/components/searchbox/v2/__test__/testUtils.ts`](../../src/components/searchbox/v2/__test__/testUtils.ts) exports a shared `testI18n` instance.
### Asserting on translation keys
With empty messages, `t('foo.bar')` returns `'foo.bar'` (the key). Assert against the key directly — no need to mock `t`:
```typescript
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'mediaAsset.selection.exportStarted' })
)
```
For pluralization / interpolation arguments, spy on the consumer (e.g. the toast `add` fn) and inspect the captured payload, rather than spying on `t` itself.
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns

View File

@@ -55,9 +55,7 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**'
'.agents/checks/eslint.strict.config.js'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.15",
"version": "1.44.13",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -83,7 +83,6 @@
"@tiptap/extension-table-row": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
@@ -114,7 +113,6 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",

View File

@@ -27,23 +27,6 @@
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
/* Plum */
--color-plum-300: #afa3db;
--color-plum-400: #8d7fc5;
--color-plum-500: #6b5ca8;
--color-plum-600: #49378b;
/* Ink */
--color-ink-100: #5c5362;
--color-ink-200: #4f4754;
--color-ink-300: #413b45;
--color-ink-400: #353139;
--color-ink-500: #312c34;
--color-ink-600: #29252c;
--color-ink-700: #232025;
--color-ink-800: #19161a;
--color-ink-900: #151317;
--color-white: #ffffff;
--color-black: #000000;

View File

@@ -41,6 +41,10 @@
--color-sand-300: #888682;
--color-sand-400: #eed7ac;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-azure-300: #78bae9;
--color-azure-400: #31b9f4;
--color-azure-600: #0b8ce9;
@@ -49,6 +53,7 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9c9eab;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -203,7 +208,7 @@
--node-component-slot-dot-outline-opacity: 5%;
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-ash-800);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-surface-highlight: var(--color-ash-500);
--node-component-surface-hovered: var(--color-smoke-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
@@ -222,7 +227,7 @@
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
--text-secondary: var(--color-smoke-800);
--text-secondary: var(--color-ash-500);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
@@ -259,7 +264,7 @@
--secondary-background-selected
);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-background-highlighted: var(--color-ash-500);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
@@ -339,19 +344,19 @@
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-smoke-800);
--node-component-header-icon: var(--color-slate-300);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-smoke-700);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-slot-text: var(--color-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-600);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-charcoal-200);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
@@ -369,7 +374,7 @@
);
--color-interface-panel-job-progress-border: var(--base-foreground);
--text-secondary: var(--color-smoke-700);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-white);
--input-surface: rgb(130 130 130 / 0.1);
@@ -409,7 +414,7 @@
--component-node-widget-background-disabled: var(
--color-alpha-charcoal-600-30
);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);

View File

@@ -4062,22 +4062,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/seedance/virtual-library/assets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["seedanceVirtualLibraryCreateAsset"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/seedance/complete": {
parameters: {
query?: never;
@@ -14506,16 +14490,6 @@ export interface components {
code: string;
message: string;
};
SeedanceVirtualLibraryCreateAssetRequest: {
/** @description Publicly accessible URL of the image asset to upload to the caller's virtual portrait library. */
url: string;
/** @description Client-supplied content hash used as the per-customer dedup key. Re-submitting the same hash returns the existing asset id without re-uploading to BytePlus. */
hash: string;
};
SeedanceVirtualLibraryCreateAssetResponse: {
/** @description BytePlus-issued asset id. Clients poll seedanceGetAsset with this until status == Active. */
asset_id: string;
};
WanVideoGenerationRequest: {
/**
* @description The ID of the model to call
@@ -30364,7 +30338,10 @@ export interface operations {
};
seedanceGetAsset: {
parameters: {
query?: never;
query?: {
/** @description BytePlus project name. Defaults to "default" if omitted. Must match the ProjectName used at create time. */
project_name?: string;
};
header?: never;
path: {
/** @description BytePlus-issued asset id returned by seedanceCreateAsset */
@@ -30394,39 +30371,6 @@ export interface operations {
};
};
};
seedanceVirtualLibraryCreateAsset: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetRequest"];
};
};
responses: {
/** @description Asset creation accepted (asynchronous — poll seedanceGetAsset) */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetResponse"];
};
};
/** @description Error 4xx/5xx */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
seedanceVisualValidateCallback: {
parameters: {
query: {

View File

@@ -8,10 +8,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
workers: 1,
use: {
trace: 'on',
video: 'on',
launchOptions: {
slowMo: Number(process.env.SLOW_MO) || 0
}
video: 'on'
}
}
: {

41
pnpm-lock.yaml generated
View File

@@ -162,9 +162,6 @@ catalogs:
'@types/three':
specifier: ^0.169.0
version: 0.169.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1
@@ -363,9 +360,6 @@ catalogs:
unplugin-vue-components:
specifier: ^30.0.0
version: 30.0.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4
@@ -503,9 +497,6 @@ importers:
'@tiptap/starter-kit':
specifier: 'catalog:'
version: 2.27.2
'@vee-validate/zod':
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
'@vueuse/core':
specifier: 'catalog:'
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
@@ -596,9 +587,6 @@ importers:
typegpu:
specifier: 'catalog:'
version: 0.8.2
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -961,9 +949,6 @@ importers:
lenis:
specifier: 'catalog:'
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
posthog-js:
specifier: 'catalog:'
version: 1.358.1
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4736,11 +4721,6 @@ packages:
peerDependencies:
valibot: ^1.2.0
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vercel/analytics@2.0.1':
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
peerDependencies:
@@ -9613,11 +9593,6 @@ packages:
typescript:
optional: true
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.4.26
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -14063,14 +14038,6 @@ snapshots:
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
zod: 3.25.76
transitivePeerDependencies:
- vue
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
optionalDependencies:
react: 19.2.4
@@ -14189,7 +14156,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20084,12 +20051,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
type-fest: 4.41.0
vue: 3.5.13(typescript@5.9.3)
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3

View File

@@ -55,7 +55,6 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
@@ -122,7 +121,6 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vee-validate: ^4.15.1
vite: ^8.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2

View File

@@ -15,7 +15,7 @@ import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { isStaleChunkError, parsePreloadError } from '@/utils/preloadErrorUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const workspaceStore = useWorkspaceStore()
@@ -92,17 +92,14 @@ onMounted(() => {
}
})
}
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
if (isStaleChunkError(info)) {
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
}
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -1,8 +1,5 @@
<template>
<div
class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1"
data-testid="bounding-box"
>
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>

View File

@@ -1,5 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { describe, expect, it, vi } from 'vitest'
import EditableText from './EditableText.vue'
@@ -15,6 +17,10 @@ describe('EditableText', () => {
const user = userEvent.setup()
render(EditableText, {
global: {
plugins: [PrimeVue],
components: { InputText }
},
props: {
...props,
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),

View File

@@ -1,18 +1,24 @@
<template>
<div class="editable-text inline">
<div class="editable-text">
<component :is="labelType" v-if="!isEditing" :class="labelClass">
{{ modelValue }}
</component>
<Input
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<InputText
v-else
ref="inputRef"
v-model="inputValue"
v-model:model-value="inputValue"
v-focus
type="text"
class="h-full rounded-none p-0 focus-visible:ring-0"
v-bind="inputAttrs"
@blur="finishEditing"
@keydown.enter.capture.stop="inputRef?.blur()"
size="small"
fluid
:pt="{
root: {
onBlur: finishEditing,
...inputAttrs
}
}"
@keydown.enter.capture.stop="blurInputElement"
@keydown.escape.capture.stop="cancelEditing"
@click.stop
@contextmenu.stop
@@ -23,10 +29,9 @@
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
import Input from '@/components/ui/input/Input.vue'
const {
modelValue,
isEditing = false,
@@ -43,23 +48,30 @@ const {
const emit = defineEmits(['edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof Input>>()
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const isCanceling = ref(false)
function finishEditing() {
const blurInputElement = () => {
// @ts-expect-error - $el is an internal property of the InputText component
inputRef.value?.$el.blur()
}
const finishEditing = () => {
// Don't save if we're canceling
if (!isCanceling.value) {
emit('edit', inputValue.value)
}
isCanceling.value = false
}
function cancelEditing() {
const cancelEditing = () => {
// Set canceling flag to prevent blur from saving
isCanceling.value = true
// Reset to original value
inputValue.value = modelValue
// Emit cancel event
emit('cancel')
inputRef.value?.blur()
// Blur the input to exit edit mode
blurInputElement()
}
watch(
() => isEditing,
async (newVal) => {
@@ -70,14 +82,27 @@ watch(
const fileName = inputValue.value.includes('.')
? inputValue.value.split('.').slice(0, -1).join('.')
: inputValue.value
inputRef.value.setSelectionRange(0, fileName.length)
const start = 0
const end = fileName.length
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange?.(start, end)
})
}
},
{ immediate: true }
)
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
}
</script>
<style scoped>
.editable-text {
display: inline;
}
.editable-text input {
width: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -1,85 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import { useDialogStore } from '@/stores/dialogStore'
import PromptDialogContent from './PromptDialogContent.vue'
type Props = ComponentProps<typeof PromptDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('PromptDialogContent', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function renderComponent(props: Partial<Props> = {}) {
const user = userEvent.setup()
render(PromptDialogContent, {
global: { plugins: [i18n] },
props: {
message: 'Enter a name',
defaultValue: '',
onConfirm: vi.fn(),
...props
} as Props
})
return { user }
}
it('pre-fills the input with defaultValue', () => {
renderComponent({ defaultValue: 'my workflow' })
expect(screen.getByRole('textbox')).toHaveValue('my workflow')
})
it('calls onConfirm and closes dialog when Confirm is clicked', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'renamed')
await user.click(screen.getByRole('button', { name: /confirm/i }))
expect(onConfirm).toHaveBeenCalledWith('renamed')
expect(closeSpy).toHaveBeenCalledOnce()
})
it('calls onConfirm when Enter is pressed inside the input', async () => {
const onConfirm = vi.fn()
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'via enter')
await user.keyboard('{Enter}')
expect(onConfirm).toHaveBeenCalledWith('via enter')
})
it('closes dialog when Confirm button is clicked', async () => {
const { user } = renderComponent({ defaultValue: '' })
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
await user.click(screen.getByRole('button', { name: /confirm/i }))
expect(closeSpy).toHaveBeenCalledOnce()
})
it('selects all text when the input is focused', async () => {
renderComponent({ defaultValue: 'pre-filled text', onConfirm: vi.fn() })
const input = screen.getByRole('textbox') as HTMLInputElement
const spy = vi.spyOn(input, 'setSelectionRange')
await fireEvent.focus(input)
expect(spy).toHaveBeenCalledWith(0, 'pre-filled text'.length)
})
})

View File

@@ -1,43 +1,49 @@
<template>
<div class="prompt-dialog-content flex flex-col gap-2 pt-8">
<label class="flex flex-col gap-1 text-sm text-muted-foreground">
{{ message }}
<Input
<FloatLabel>
<InputText
ref="inputRef"
v-model="inputValue"
type="text"
:placeholder
autofocus
@keyup.enter="handleConfirm"
@focus="inputRef?.selectAll()"
@keyup.enter="onConfirm"
@focus="selectAllText"
/>
</label>
<Button @click="handleConfirm">
<label>{{ message }}</label>
</FloatLabel>
<Button @click="onConfirm">
{{ $t('g.confirm') }}
</Button>
</div>
</template>
<script setup lang="ts">
import FloatLabel from 'primevue/floatlabel'
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { message, defaultValue, onConfirm, placeholder } = defineProps<{
const props = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(defaultValue)
const inputValue = ref<string>(props.defaultValue)
function handleConfirm() {
onConfirm(inputValue.value)
const onConfirm = () => {
props.onConfirm(inputValue.value)
useDialogStore().closeDialog()
}
const inputRef = ref<InstanceType<typeof Input>>()
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const selectAllText = () => {
if (!inputRef.value) return
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(0, inputElement.value.length)
}
</script>

View File

@@ -1,110 +0,0 @@
import { Form, FormField } from '@primevue/forms'
import { render, screen } from '@testing-library/vue'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import SignUpForm from './SignUpForm.vue'
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
const mockLoadingRef = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoadingRef.value
}
}))
}))
describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(SignUpForm, {
global: {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
}
})
}
describe('Password manager autofill attributes', () => {
it('renders email input with attributes Chrome needs to recognize the field', () => {
renderComponent()
const emailInput = screen.getByPlaceholderText(
enMessages.auth.signup.emailPlaceholder
)
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email')
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveAttribute('type', 'email')
})
it('renders password input with new-password autofill attributes', () => {
renderComponent()
const passwordInput = screen.getByPlaceholderText(
enMessages.auth.signup.passwordPlaceholder
)
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password')
expect(passwordInput).toHaveAttribute('name', 'password')
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
})
it('renders confirm-password input with distinct name and new-password autocomplete', () => {
renderComponent()
const confirmPasswordInput = screen.getByPlaceholderText(
enMessages.auth.login.confirmPasswordPlaceholder
)
expect(confirmPasswordInput).toHaveAttribute(
'id',
'comfy-org-sign-up-confirm-password'
)
expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword')
expect(confirmPasswordInput).toHaveAttribute(
'autocomplete',
'new-password'
)
})
})
})

View File

@@ -15,10 +15,9 @@
</label>
<InputText
pt:root:id="comfy-org-sign-up-email"
pt:root:name="email"
pt:root:autocomplete="email"
class="h-10"
type="email"
type="text"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="$field.invalid"
/>

View File

@@ -8,6 +8,11 @@
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
>
<!-- Native drag area for Electron -->
<div
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
/>
<div
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
>
@@ -184,6 +189,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'

View File

@@ -140,11 +140,11 @@ const iconClass = computed(() => {
const iconColorClass = computed(() => {
if (notification.type === 'queuedPending') {
return 'animate-spin text-text-secondary'
return 'animate-spin text-slate-100'
}
if (notification.type === 'failed') {
return 'text-danger-200'
}
return 'text-text-secondary'
return 'text-slate-100'
})
</script>

View File

@@ -0,0 +1,136 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined },
state: { type: String, required: true },
title: { type: String, required: true },
rightText: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconImageUrl: { type: String, default: undefined },
showClear: { type: Boolean, default: undefined },
showMenu: { type: Boolean, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined },
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
template: `
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
</div>
`
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
const { taskRef, ...rest } = overrides
return {
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: (taskRef ?? {
workflow: { id: 'workflow-id' }
}) as TaskItemImpl,
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...rest
}
}
function getActiveDetailsId(container: Element, jobId: string): string | null {
return (
container
.querySelector(`[data-job-id="${jobId}"]`)
?.getAttribute('data-active-details-id') ?? null
)
}
const renderComponent = (groups: JobGroup[]) =>
render(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
QueueJobItem: QueueJobItemStub
}
}
})
describe('JobGroupsList hover behavior', () => {
afterEach(() => {
vi.useRealTimers()
})
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const { container } = renderComponent([
{ key: 'today', label: 'Today', items: [job] }
])
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-d'))
vi.advanceTimersByTime(199)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-d'))
vi.advanceTimersByTime(149)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const { container } = renderComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-1'))
vi.advanceTimersByTime(200)
await nextTick()
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-1'))
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-2'))
vi.advanceTimersByTime(100)
await nextTick()
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-2'))
vi.advanceTimersByTime(50)
await nextTick()
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
})
})

View File

@@ -0,0 +1,82 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-[12px] leading-none text-text-secondary">
{{ group.label }}
</div>
<QueueJobItem
v-for="ji in group.items"
:key="ji.id"
:job-id="ji.id"
:workflow-id="ji.taskRef?.workflowId"
:state="ji.state"
:title="ji.title"
:right-text="ji.meta"
:icon-name="ji.iconName"
:icon-image-url="ji.iconImageUrl"
:show-clear="ji.showClear"
:show-menu="true"
:progress-total-percent="ji.progressTotalPercent"
:progress-current-percent="ji.progressCurrentPercent"
:running-node-name="ji.runningNodeName"
:active-details-id="activeDetailsId"
@cancel="emitCancelItem(ji)"
@delete="emitDeleteItem(ji)"
@menu="(ev) => $emit('menu', ji, ev)"
@view="$emit('viewItem', ji)"
@details-enter="onDetailsEnter"
@details-leave="onDetailsLeave"
/>
</div>
</div>
</template>
<script setup lang="ts">
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const {
activeDetails: activeDetailsId,
clearHoverTimers,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<string>({
getActiveId: (jobId) => jobId,
getDisplayedJobGroups: () => displayedJobGroups
})
function emitCancelItem(item: JobListItem) {
emit('cancelItem', item)
}
function emitDeleteItem(item: JobListItem) {
emit('deleteItem', item)
}
function onDetailsEnter(jobId: string) {
if (activeDetailsId.value === jobId) {
clearHoverTimers()
return
}
scheduleDetailsShow(jobId)
}
function onDetailsLeave(jobId: string) {
scheduleDetailsHide(jobId)
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
<div class="p-3">
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
<img
ref="imgRef"
:src="imageUrl"
:alt="name"
class="size-full cursor-pointer object-contain"
@click="$emit('image-click')"
@load="onImgLoad"
/>
<div
v-if="timeLabel"
class="absolute bottom-2 left-2 rounded-sm px-2 py-0.5 text-xs text-text-primary"
:style="{
background: 'rgba(217, 217, 217, 0.40)',
backdropFilter: 'blur(2px)'
}"
>
{{ timeLabel }}
</div>
</div>
<div class="mt-2 text-center">
<div
class="truncate text-sm/normal font-semibold text-text-primary"
:title="name"
>
{{ name }}
</div>
<div
v-if="width && height"
class="mt-1 text-xs/normal text-text-secondary"
>
{{ width }}x{{ height }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps<{
imageUrl: string
name: string
timeLabel?: string
}>()
defineEmits(['image-click'])
const imgRef = ref<HTMLImageElement | null>(null)
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const onImgLoad = () => {
const el = imgRef.value
if (!el) return
width.value = el.naturalWidth || null
height.value = el.naturalHeight || null
}
</script>

View File

@@ -0,0 +1,133 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QueueJobItem from './QueueJobItem.vue'
const meta: Meta<typeof QueueJobItem> = {
title: 'Queue/QueueJobItem',
component: QueueJobItem,
parameters: {
layout: 'padded'
},
argTypes: {
onCancel: { action: 'cancel' },
onDelete: { action: 'delete' },
onMenu: { action: 'menu' },
onView: { action: 'view' }
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
export const PendingRecentlyAdded: Story = {
args: {
jobId: 'job-pending-added-1',
state: 'pending',
title: 'Job added to queue',
rightText: '12:30 PM',
iconName: 'icon-[lucide--check]'
}
}
export const Pending: Story = {
args: {
jobId: 'job-pending-1',
state: 'pending',
title: 'Pending job',
rightText: '12:31 PM'
}
}
export const Initialization: Story = {
args: {
jobId: 'job-init-1',
state: 'initialization',
title: 'Initializing...'
}
}
export const RunningTotalOnly: Story = {
args: {
jobId: 'job-running-1',
state: 'running',
title: 'Generating image',
progressTotalPercent: 42
}
}
export const RunningWithCurrent: Story = {
args: {
jobId: 'job-running-2',
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10
}
}
export const CompletedWithPreview: Story = {
args: {
jobId: 'job-completed-1',
state: 'completed',
title: 'Prompt #1234',
rightText: '12.79s',
iconImageUrl: thumb('4dabf7')
}
}
export const CompletedNoPreview: Story = {
args: {
jobId: 'job-completed-2',
state: 'completed',
title: 'Prompt #5678',
rightText: '8.12s'
}
}
export const Failed: Story = {
args: {
jobId: 'job-failed-1',
state: 'failed',
title: 'Failed job',
rightText: 'Failed'
}
}
export const Gallery: Story = {
render: (args) => ({
components: { QueueJobItem },
setup() {
return { args }
},
template: `
<div class="flex flex-col gap-2 w-[420px]">
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
<QueueJobItem
job-id="job-running-2"
state="running"
title="Generating image"
:progress-total-percent="66"
:progress-current-percent="10"
running-node-name="KSampler"
v-bind="args"
/>
<QueueJobItem
job-id="job-completed-1"
state="completed"
title="Prompt #1234"
right-text="12.79s"
icon-image-url="${thumb('4dabf7')}"
v-bind="args"
/>
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
</div>
`
})
}

View File

@@ -0,0 +1,362 @@
<template>
<div
ref="rowRef"
class="relative"
@mouseenter="onRowEnter"
@mouseleave="onRowLeave"
@contextmenu.stop.prevent="onContextMenu"
>
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
</div>
</Teleport>
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
}"
@mouseenter="onPreviewEnter"
@mouseleave="onPreviewLeave"
>
<QueueAssetPreview
:image-url="iconImageUrl!"
:name="title"
:time-label="rightText || undefined"
@image-click="emit('view')"
/>
</div>
</Teleport>
<div
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div
v-if="
state === 'running' &&
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
"
:class="progressBarContainerClass"
>
<div
v-if="hasProgressPercent(progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(progressTotalPercent)"
/>
<div
v-if="hasProgressPercent(progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(progressCurrentPercent)"
/>
</div>
<div class="relative z-1 flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute top-1/2 left-1/2 size-10 -translate-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
/>
<div
class="inline-flex size-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="size-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
/>
</div>
</div>
</div>
<div class="relative z-1 min-w-0 flex-1">
<div class="truncate opacity-90" :title="title">
<slot name="primary">{{ title }}</slot>
</div>
</div>
<!--
TODO: Refactor action buttons to use a declarative config system.
Instead of hardcoding button visibility logic in the template, define an array of
action button configs with properties like:
- icon, label, action, tooltip
- visibleStates: JobState[] (which job states show this button)
- alwaysVisible: boolean (show without hover)
- destructive: boolean (use destructive styling)
Then render buttons in two groups:
1. Always-visible buttons (outside Transition)
2. Hover-only buttons (inside Transition)
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
<div class="relative z-1 flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
enter-from-class="opacity-0 translate-y-0.5"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-0.5"
>
<div
v-if="isHovered"
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<Button
v-if="state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="
state !== 'completed' &&
state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<Button
v-if="showMenu !== undefined ? showMenu : true"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</div>
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
<slot name="secondary">{{ rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<Button
v-if="state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@comfyorg/tailwind-utils'
const {
jobId,
workflowId,
state,
title,
rightText = '',
iconName,
iconImageUrl,
showClear,
showMenu,
progressTotalPercent,
progressCurrentPercent,
activeDetailsId = null
} = defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'delete'): void
(e: 'menu', event: MouseEvent): void
(e: 'view'): void
(e: 'details-enter', jobId: string): void
(e: 'details-leave', jobId: string): void
}>()
const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => activeDetailsId === jobId)
const onRowEnter = () => {
if (!isPreviewVisible.value) emit('details-enter', jobId)
}
const onRowLeave = () => emit('details-leave', jobId)
const onPopoverEnter = () => emit('details-enter', jobId)
const onPopoverLeave = () => emit('details-leave', jobId)
const isPreviewVisible = ref(false)
const previewHideTimer = ref<number | null>(null)
const previewShowTimer = ref<number | null>(null)
const clearPreviewHideTimer = () => {
if (previewHideTimer.value !== null) {
clearTimeout(previewHideTimer.value)
previewHideTimer.value = null
}
}
const clearPreviewShowTimer = () => {
if (previewShowTimer.value !== null) {
clearTimeout(previewShowTimer.value)
previewShowTimer.value = null
}
}
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
const scheduleShowPreview = () => {
if (!canShowPreview.value) return
clearPreviewHideTimer()
clearPreviewShowTimer()
previewShowTimer.value = window.setTimeout(() => {
isPreviewVisible.value = true
previewShowTimer.value = null
}, 200)
}
const scheduleHidePreview = () => {
clearPreviewHideTimer()
clearPreviewShowTimer()
previewHideTimer.value = window.setTimeout(() => {
isPreviewVisible.value = false
previewHideTimer.value = null
}, 150)
}
const onIconEnter = () => scheduleShowPreview()
const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const updatePopoverPosition = () => {
const el = rowRef.value
if (!el) return
const rect = el.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
}
const isAnyPopoverVisible = computed(
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
)
watch(
isAnyPopoverVisible,
(visible) => {
if (visible) {
nextTick(updatePopoverPosition)
} else {
popoverPosition.value = null
}
},
{ immediate: false }
)
const isHovered = ref(false)
const iconClass = computed(() => {
if (iconName) return iconName
return iconForJobState(state)
})
const shouldSpin = computed(
() =>
state === 'pending' &&
iconClass.value === iconForJobState('pending') &&
!iconImageUrl
)
const computedShowClear = computed(() => {
if (showClear !== undefined) return showClear
return state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', jobId)
const onCancelClick = () => {
emitDetailsLeave()
emit('cancel')
}
const onDeleteClick = () => {
emitDetailsLeave()
emit('delete')
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = showMenu !== undefined ? showMenu : true
if (shouldShowMenu) emit('menu', event)
}
</script>

View File

@@ -5,11 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
setViewport,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -17,14 +15,10 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
const DESKTOP_VIEWPORT = { width: 1280, height: 800 }
const MOBILE_VIEWPORT = { width: 360, height: 800 }
describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
setViewport(DESKTOP_VIEWPORT)
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
@@ -553,7 +547,7 @@ describe('NodeSearchContent', () => {
})
describe('filter integration', () => {
it('renders one chip per active filter with the filter value', () => {
it('should display active filters in the input area', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
@@ -562,20 +556,16 @@ describe('NodeSearchContent', () => {
})
])
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
renderComponent({
filters: [
{ filterDef: inputFilter, value: 'IMAGE' },
{ filterDef: inputFilter, value: 'LATENT' }
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
value: 'IMAGE'
}
]
})
const chipTexts = screen
.getAllByTestId('filter-chip')
.map((c) => c.textContent ?? '')
expect(chipTexts).toHaveLength(2)
expect(chipTexts.some((t) => t.includes('IMAGE'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
})
})
@@ -669,95 +659,6 @@ describe('NodeSearchContent', () => {
})
})
describe('sidebar toggle', () => {
it('should hide and show the category sidebar when the toggle is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
})
])
const { user } = renderComponent()
const sidebar = await screen.findByTestId('category-sampling')
expect(sidebar).toBeVisible()
const toggle = screen.getByTestId('toggle-category-sidebar')
expect(toggle).toHaveAttribute('aria-expanded', 'true')
await user.click(toggle)
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('category-sampling')).not.toBeVisible()
})
await user.click(toggle)
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('category-sampling')).toBeVisible()
})
})
it('should close the sidebar when the search input gains focus on mobile', async () => {
setViewport(MOBILE_VIEWPORT)
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
})
])
const { user } = renderComponent()
const toggle = screen.getByTestId('toggle-category-sidebar')
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-expanded', 'false')
})
})
it('should preserve user state across mobile/desktop resizes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
})
])
const { user } = renderComponent()
const toggle = screen.getByTestId('toggle-category-sidebar')
const expectExpanded = (value: 'true' | 'false') =>
waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', value))
await expectExpanded('true')
setViewport(MOBILE_VIEWPORT)
await expectExpanded('false')
await user.click(toggle)
setViewport(DESKTOP_VIEWPORT)
await expectExpanded('true')
await user.click(toggle)
setViewport(MOBILE_VIEWPORT)
await expectExpanded('false')
setViewport(DESKTOP_VIEWPORT)
await expectExpanded('false')
})
})
describe('rootFilter + category + search combination', () => {
it('should intersect rootFilter, selected category, and search query', async () => {
useNodeDefStore().updateNodeDefs([

View File

@@ -13,13 +13,11 @@
@navigate-down="navigateResults(1)"
@navigate-up="navigateResults(-1)"
@select-current="selectCurrentResult"
@focusin="onSearchFocus"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
v-model:is-sidebar-open="isSidebarOpen"
class="flex-1"
:filters="filters"
:active-category="rootFilter"
@@ -36,13 +34,11 @@
</div>
<!-- Content area -->
<div class="relative flex min-h-0 flex-1 overflow-hidden">
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar -->
<NodeSearchCategorySidebar
v-show="isSidebarOpen"
id="node-search-category-sidebar"
v-model:selected-category="sidebarCategory"
:aria-label="isMobile ? t('g.categories') : undefined"
class="w-52 shrink-0 max-md:absolute max-md:inset-y-0 max-md:left-0 max-md:z-20 max-md:bg-base-background max-md:shadow-interface"
class="w-52 shrink-0"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:node-defs="rootFilteredNodeDefs"
@@ -51,14 +47,6 @@
@auto-expand="selectedCategory = $event"
/>
<!-- Mobile overlay backdrop to close sidebar on outside click -->
<div
v-if="isMobile && isSidebarOpen"
data-testid="sidebar-backdrop"
class="absolute inset-0 z-10 md:hidden"
@click="isSidebarOpen = false"
/>
<!-- Results list -->
<div
id="results-list"
@@ -90,8 +78,8 @@
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="rootFilter !== RootCategory.Essentials"
:hide-bookmark-icon="selectedCategory === RootCategory.Favorites"
:show-source-badge="rootFilter !== 'essentials'"
:hide-bookmark-icon="selectedCategory === 'favorites'"
/>
</div>
<div
@@ -108,7 +96,6 @@
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -119,8 +106,6 @@ import NodeSearchCategorySidebar, {
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -136,9 +121,9 @@ import { cn } from '@comfyorg/tailwind-utils'
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
{
[RootCategory.Essentials]: isEssentialNode,
[RootCategory.Comfy]: (n) => n.nodeSource.type === NodeSourceType.Core,
[RootCategory.Custom]: isCustomNode
essentials: isEssentialNode,
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
custom: isCustomNode
}
const { filters } = defineProps<{
@@ -182,33 +167,22 @@ const searchQuery = ref('')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedIndex = ref(0)
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
const isSidebarOpen = ref(!isMobile.value)
watch(isMobile, (mobile) => {
// On transitioning to mobile state, close the sidebar
if (mobile) isSidebarOpen.value = false
})
function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilter = ref<string | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {
case RootCategory.Favorites:
case 'favorites':
return t('g.bookmarked')
case RootCategory.Blueprint:
case BLUEPRINT_CATEGORY:
return t('g.blueprints')
case RootCategory.PartnerNodes:
case 'partner-nodes':
return t('g.partner')
case RootCategory.Essentials:
case 'essentials':
return t('g.essentials')
case RootCategory.Comfy:
case 'comfy':
return t('g.comfy')
case RootCategory.Custom:
case 'custom':
return t('g.extensions')
default:
return undefined
@@ -221,11 +195,11 @@ const rootFilteredNodeDefs = computed(() => {
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case RootCategory.Favorites:
case 'favorites':
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case RootCategory.Blueprint:
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
case RootCategory.PartnerNodes:
case BLUEPRINT_CATEGORY:
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
case 'partner-nodes':
return allNodes.filter((n) => n.api_node)
default:
return allNodes
@@ -252,7 +226,7 @@ function onClearFilterGroup(filterId: string) {
}
}
function onSelectCategory(category: RootCategoryId) {
function onSelectCategory(category: string) {
if (rootFilter.value === category) {
rootFilter.value = null
} else {

View File

@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -9,16 +9,23 @@ import {
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
describe(NodeSearchFilterBar, () => {
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
@@ -31,13 +38,8 @@ describe(NodeSearchFilterBar, () => {
async function createRender(props = {}) {
const user = userEvent.setup()
const onSelectCategory = vi.fn()
const onUpdateIsSidebarOpen = vi.fn()
render(NodeSearchFilterBar, {
props: {
onSelectCategory,
'onUpdate:isSidebarOpen': onUpdateIsSidebarOpen,
...props
},
props: { onSelectCategory, ...props },
global: {
plugins: [testI18n],
stubs: {
@@ -49,38 +51,51 @@ describe(NodeSearchFilterBar, () => {
}
})
await nextTick()
return { user, onSelectCategory, onUpdateIsSidebarOpen }
return { user, onSelectCategory }
}
const buttonTexts = () =>
screen.getAllByRole('button').map((b) => b.textContent?.trim())
it('should render Extensions button and Input/Output popover triggers', async () => {
await createRender({ hasCustomNodes: true })
it.each([
{ prop: 'hasFavorites', label: 'Bookmarked' },
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
{ prop: 'hasEssentialNodes', label: 'Essentials' },
{ prop: 'hasPartnerNodes', label: 'Partner' },
{ prop: 'hasCustomNodes', label: 'Extensions' }
] as const)(
'shows the $label button only when $prop is true',
async ({ prop, label }) => {
await createRender()
expect(buttonTexts()).not.toContain(label)
cleanup()
await createRender({ [prop]: true })
expect(buttonTexts()).toContain(label)
}
)
it('always renders the Comfy button and Input/Output type filter triggers', async () => {
await createRender()
const texts = buttonTexts()
expect(texts).toContain('Comfy')
const buttons = screen.getAllByRole('button')
const texts = buttons.map((b) => b.textContent?.trim())
expect(texts).toContain('Extensions')
expect(texts).toContain('Input')
expect(texts).toContain('Output')
})
it('should always render Comfy button', async () => {
await createRender()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).toContain('Comfy')
})
it('should render conditional category buttons when matching nodes exist', async () => {
await createRender({
hasFavorites: true,
hasEssentialNodes: true,
hasBlueprintNodes: true,
hasPartnerNodes: true
})
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).toContain('Bookmarked')
expect(texts).toContain('Blueprints')
expect(texts).toContain('Partner')
expect(texts).toContain('Essentials')
})
it('should not render Extensions button when no custom nodes exist', async () => {
await createRender()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).not.toContain('Extensions')
})
it('should emit selectCategory when category button is clicked', async () => {
const { user, onSelectCategory } = await createRender({
hasCustomNodes: true
@@ -99,24 +114,4 @@ describe(NodeSearchFilterBar, () => {
'true'
)
})
it('should expose aria-expanded=false and emit update:isSidebarOpen=true when toggled from collapsed', async () => {
const { user, onUpdateIsSidebarOpen } = await createRender({
isSidebarOpen: false
})
const toggle = screen.getByTestId('toggle-category-sidebar')
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await user.click(toggle)
expect(onUpdateIsSidebarOpen).toHaveBeenCalledExactlyOnceWith(true)
})
it('should expose aria-expanded=true when isSidebarOpen prop is true', async () => {
await createRender({ isSidebarOpen: true })
expect(screen.getByTestId('toggle-category-sidebar')).toHaveAttribute(
'aria-expanded',
'true'
)
})
})

View File

@@ -1,67 +1,48 @@
<template>
<div class="flex min-w-0 items-center gap-2.5 pl-3">
<div class="flex items-center gap-2.5 px-3">
<!-- Category filter buttons -->
<button
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
data-testid="toggle-category-sidebar"
aria-controls="node-search-category-sidebar"
:aria-expanded="isSidebarOpen"
:aria-label="isSidebarOpen ? t('g.hideLeftPanel') : t('g.showLeftPanel')"
:class="chipClass(isSidebarOpen)"
@click="isSidebarOpen = !isSidebarOpen"
:data-testid="`search-category-${btn.id}`"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
>
<i class="icon-[lucide--panel-left] size-4" />
{{ btn.label }}
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<div
data-testid="filter-chips-scroll"
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto pr-3"
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<!-- Category filter buttons -->
<button
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:data-testid="`search-category-${btn.id}`"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
:data-testid="`search-filter-${tf.chip.key}`"
:class="chipClass(false, tf.values.length > 0)"
>
{{ btn.label }}
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button
type="button"
:data-testid="`search-filter-${tf.chip.key}`"
:class="chipClass(false, tf.values.length > 0)"
>
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
</NodeSearchTypeFilterPopover>
</div>
</NodeSearchTypeFilterPopover>
</div>
</template>
@@ -82,7 +63,6 @@ import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
@@ -106,13 +86,11 @@ const {
hasCustomNodes?: boolean
}>()
const isSidebarOpen = defineModel<boolean>('isSidebarOpen', { default: true })
const emit = defineEmits<{
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
clearFilterGroup: [filterId: string]
focusSearch: []
selectCategory: [category: RootCategoryId]
selectCategory: [category: string]
}>()
const { t } = useI18n()
@@ -121,20 +99,20 @@ const nodeDefStore = useNodeDefStore()
const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: RootCategoryId; label: string }[] = []
const buttons: { id: string; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
}
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
if (hasEssentialNodes) {
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
}
if (hasPartnerNodes) {
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
}
if (hasEssentialNodes) {
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
}
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
if (hasCustomNodes) {
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
}
@@ -168,7 +146,7 @@ const typeFilters = computed(() => [
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections

View File

@@ -57,19 +57,6 @@ describe('NodeSearchListItem', () => {
})
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
})
it('hides id name for subgraph blueprints even when ShowIdName is enabled', () => {
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
true
renderItem({
nodeDef: createMockNodeDef({
name: 'SubgraphBlueprint.e21be61fc452df75e1324e3cc97c41fb0c01a08a5dad4dcd3a2ac118d8907025',
display_name: 'My Blueprint',
python_module: 'blueprint'
})
})
expect(screen.queryByTestId('node-id-badge')).not.toBeInTheDocument()
})
})
describe('showDescription mode', () => {

View File

@@ -155,10 +155,8 @@ const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(
() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const showNodeFrequency = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import type { DetachedWindowAPI } from 'happy-dom'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
@@ -36,12 +35,3 @@ export const testI18n = createI18n({
locale: 'en',
messages: { en: enMessages }
})
export function setViewport(viewport: { width: number; height: number }) {
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
.happyDOM
if (!happyDOM) {
throw new Error('window.happyDOM is unavailable to set viewport')
}
happyDOM.setViewport(viewport)
}

View File

@@ -327,7 +327,7 @@ const {
} = useAssetSelection()
const {
downloadAssets,
downloadMultipleAssets,
deleteAssets,
addMultipleToWorkflow,
openMultipleWorkflows,
@@ -533,7 +533,7 @@ function handleContextMenuHide() {
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadAssets(assets)
downloadMultipleAssets(assets)
clearSelection()
}
@@ -559,7 +559,7 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
}
const handleDownloadSelected = () => {
downloadAssets(selectedAssets.value)
downloadMultipleAssets(selectedAssets.value)
clearSelection()
}

View File

@@ -209,7 +209,7 @@ const dotClasses = computed(() => {
return 'bg-gold-600'
case 'info':
default:
return 'bg-text-secondary'
return 'bg-slate-100'
}
})

View File

@@ -14,12 +14,7 @@ const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select(),
blur: () => inputRef.value?.blur(),
setSelectionRange: (start: number, end: number) =>
inputRef.value?.setSelectionRange(start, end),
selectAll: () =>
inputRef.value?.setSelectionRange(0, inputRef.value.value.length)
select: () => inputRef.value?.select()
})
</script>

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchAutocomplete from './SearchAutocomplete.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
})
describe('SearchAutocomplete', () => {
function renderComponent(props: Record<string, unknown> = {}) {
return render(SearchAutocomplete, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: { template: '<div><slot /></div>' },
ComboboxAnchor: { template: '<div><slot /></div>' },
ComboboxInput: {
template:
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue'],
emits: ['update:modelValue']
},
ComboboxPortal: { template: '<div><slot /></div>' },
ComboboxContent: { template: '<div><slot /></div>' },
ComboboxItem: {
template:
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
emits: ['select']
}
}
},
props: { modelValue: '', ...props }
})
}
describe('suggestions dropdown', () => {
it('does not render items when suggestions list is empty', () => {
renderComponent({ suggestions: [] })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders a button for each suggestion', () => {
renderComponent({ suggestions: ['foo', 'bar'] })
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
})
it('emits select with the suggestion when an item is clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
await user.click(screen.getByText('foo'))
expect(onSelect).toHaveBeenCalledWith('foo')
})
it('updates modelValue to the suggestion label on selection', async () => {
const onUpdateModelValue = vi.fn()
const user = userEvent.setup()
renderComponent({
suggestions: ['foo', 'bar'],
'onUpdate:modelValue': onUpdateModelValue
})
await user.click(screen.getByText('foo'))
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
})
})
describe('with optionLabel', () => {
it('displays the optionLabel property as the suggestion text', () => {
const suggestions = [{ id: 1, query: 'my-extension' }]
renderComponent({ suggestions, optionLabel: 'query' })
expect(screen.getByText('my-extension')).toBeInTheDocument()
})
it('emits the full item object on selection when optionLabel is set', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
const suggestions = [{ id: 1, query: 'my-extension' }]
renderComponent({ suggestions, optionLabel: 'query', onSelect })
await user.click(screen.getByText('my-extension'))
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
})
})
})

View File

@@ -65,36 +65,34 @@
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxPortal>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</template>
@@ -107,7 +105,6 @@ import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot
} from 'reka-ui'
import { computed, ref, watch } from 'vue'

View File

@@ -1,25 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
const { class: className, ...restAttrs } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
defineExpose({
focus: () => textareaEl.value?.focus()
})
</script>
<template>
<textarea
ref="textareaEl"
v-bind="restAttrs"
v-model="modelValue"
:class="
cn(

View File

@@ -35,7 +35,7 @@
v-if="$slots.header"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
>
<div class="flex min-w-0 flex-1 gap-2">
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile && !showLeftPanel"
size="lg"

View File

@@ -1,142 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
useCoordinateTransform: () => ({
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
})
}))
vi.mock('@/scripts/app', () => ({
app: { registerExtension: vi.fn() }
}))
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useBrushAdjustment } from './useBrushAdjustment'
function makePointerEvent(offsetX: number, offsetY: number): PointerEvent {
return {
offsetX,
offsetY,
preventDefault: vi.fn()
} as unknown as PointerEvent
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
})
describe('startBrushAdjustment', () => {
it('sets brushPreviewGradientVisible to true', async () => {
const store = useMaskEditorStore()
store.brushPreviewGradientVisible = false
const { startBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
expect(store.brushPreviewGradientVisible).toBe(true)
})
})
describe('handleBrushAdjustment', () => {
it('does nothing when startBrushAdjustment has not been called', async () => {
const store = useMaskEditorStore()
const sizeBefore = store.brushSettings.size
const hardnessBefore = store.brushSettings.hardness
const { handleBrushAdjustment } = useBrushAdjustment()
await handleBrushAdjustment(makePointerEvent(200, 100))
expect(store.brushSettings.size).toBe(sizeBefore)
expect(store.brushSettings.hardness).toBe(hardnessBefore)
})
it('does not change size when deltaX is within the dead zone', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
const sizeBefore = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(103, 100))
expect(store.brushSettings.size).toBe(sizeBefore)
})
it('increases size when dragging right past the dead zone', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
const sizeBefore = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(150, 100))
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
})
it('does not compound size when pointer stays at the same position', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(100, 100))
await handleBrushAdjustment(makePointerEvent(150, 100))
const sizeAfterFirstMove = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(150, 100))
expect(store.brushSettings.size).toBe(sizeAfterFirstMove)
})
it('continues increasing size beyond 100px drag (no delta saturation)', async () => {
const store = useMaskEditorStore()
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(0, 0))
await handleBrushAdjustment(makePointerEvent(100, 0))
const sizeAt100px = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(300, 0))
const sizeAt300px = store.brushSettings.size
expect(sizeAt300px).toBeGreaterThan(sizeAt100px)
})
it('clamps size to minimum 1 when dragging far left', async () => {
const store = useMaskEditorStore()
store.brushSettings.size = 2
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
await startBrushAdjustment(makePointerEvent(500, 100))
await handleBrushAdjustment(makePointerEvent(0, 100))
expect(store.brushSettings.size).toBe(1)
})
it('clamps hardness to maximum 1 when dragging far up', async () => {
const store = useMaskEditorStore()
store.brushSettings.hardness = 0.9
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
brushAdjustmentSpeed: 10
})
await startBrushAdjustment(makePointerEvent(100, 500))
await handleBrushAdjustment(makePointerEvent(100, 0))
expect(store.brushSettings.hardness).toBe(1)
})
it('suppresses hardness change when X delta dominates (useDominantAxis=true)', async () => {
const store = useMaskEditorStore()
store.brushSettings.hardness = 0.5
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
useDominantAxis: true
})
await startBrushAdjustment(makePointerEvent(0, 0))
const sizeBefore = store.brushSettings.size
await handleBrushAdjustment(makePointerEvent(100, 10))
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
expect(store.brushSettings.hardness).toBe(0.5)
})
it('suppresses size change when Y delta dominates (useDominantAxis=true)', async () => {
const store = useMaskEditorStore()
store.brushSettings.hardness = 0.5
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
useDominantAxis: true
})
await startBrushAdjustment(makePointerEvent(0, 0))
const sizeBefore = store.brushSettings.size
const hardnessBefore = store.brushSettings.hardness
await handleBrushAdjustment(makePointerEvent(10, 100))
expect(store.brushSettings.size).toBe(sizeBefore)
expect(store.brushSettings.hardness).toBeLessThan(hardnessBefore)
})
})

View File

@@ -1,83 +0,0 @@
import { ref } from 'vue'
import type { Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useCoordinateTransform } from './useCoordinateTransform'
export function useBrushAdjustment(initialSettings?: {
useDominantAxis?: boolean
brushAdjustmentSpeed?: number
}) {
const store = useMaskEditorStore()
const coordinateTransform = useCoordinateTransform()
const initialPoint = ref<Point | null>(null)
const initialBrushSize = ref(0)
const initialBrushHardness = ref(0)
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
store.brushPreviewGradientVisible = true
initialPoint.value = coords_canvas
initialBrushSize.value = store.brushSettings.size
initialBrushHardness.value = store.brushSettings.hardness
}
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
if (!initialPoint.value) {
return
}
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const delta_x = coords_canvas.x - initialPoint.value.x
const delta_y = coords_canvas.y - initialPoint.value.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
if (useDominantAxis.value) {
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0
if (ratio > threshold) {
finalDeltaY = 0
} else if (ratio < 1 / threshold) {
finalDeltaX = 0
}
}
const newSize = Math.max(
1,
Math.min(
500,
initialBrushSize.value + (finalDeltaX / 35) * brushAdjustmentSpeed.value
)
)
const newHardness = Math.max(
0,
Math.min(
1,
initialBrushHardness.value -
(finalDeltaY / 4000) * brushAdjustmentSpeed.value
)
)
store.setBrushSize(newSize)
store.setBrushHardness(newHardness)
}
return { startBrushAdjustment, handleBrushAdjustment }
}

View File

@@ -14,7 +14,6 @@ import { tgpu } from 'typegpu'
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
import { StrokeProcessor } from './StrokeProcessor'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
import { useBrushAdjustment } from './useBrushAdjustment'
import {
resetDirtyRect,
updateDirtyRect,
@@ -31,8 +30,6 @@ export function useBrushDrawing(initialSettings?: {
}) {
const store = useMaskEditorStore()
const persistence = useBrushPersistence()
const { startBrushAdjustment, handleBrushAdjustment } =
useBrushAdjustment(initialSettings)
const coordinateTransform = useCoordinateTransform()
@@ -66,6 +63,10 @@ export function useBrushDrawing(initialSettings?: {
// Stroke processor instance
let strokeProcessor: StrokeProcessor | null = null
const initialPoint = ref<Point | null>(null)
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
persistence.loadAndApply()
// Handle external clear events
@@ -752,6 +753,78 @@ export function useBrushDrawing(initialSettings?: {
}
}
/**
* Starts the brush adjustment interaction.
* @param event - The pointer event.
*/
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
store.brushPreviewGradientVisible = true
initialPoint.value = coords_canvas
}
/**
* Handles the brush adjustment movement.
* @param event - The pointer event.
*/
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
if (!initialPoint.value) {
return
}
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const delta_x = coords_canvas.x - initialPoint.value.x
const delta_y = coords_canvas.y - initialPoint.value.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
if (useDominantAxis.value) {
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0
if (ratio > threshold) {
finalDeltaY = 0
} else if (ratio < 1 / threshold) {
finalDeltaX = 0
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
const newSize = Math.max(
1,
Math.min(
500,
store.brushSettings.size +
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
)
)
const newHardness = Math.max(
0,
Math.min(
1,
store.brushSettings.hardness -
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
)
)
store.setBrushSize(newSize)
store.setBrushHardness(newHardness)
}
/**
* Reads back the GPU textures to CPU ImageDatas.
* @returns Object containing mask and rgb ImageDatas.

View File

@@ -1,70 +0,0 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock } = vi.hoisted(() => ({
canvasInteractionsMock: {
handleWheel: vi.fn(),
handlePointer: vi.fn(),
forwardEventToCanvas: vi.fn()
}
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: () => canvasInteractionsMock
}))
// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.)
// that we cannot pull in here, so we stub only the constant we need.
vi.mock('@/scripts/app', () => ({
ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview'
}))
describe('useNodeAnimatedImage', () => {
function setup() {
vi.clearAllMocks()
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
showAnimatedPreview(node)
const element = node.widgets[0].element
document.body.append(element)
onTestFinished(() => element.remove())
return { node, element, showAnimatedPreview, removeAnimatedPreview }
}
it('forwards non-right-click pointer events and wheel to the canvas while alive', () => {
const { element } = setup()
element.dispatchEvent(new WheelEvent('wheel'))
element.dispatchEvent(new PointerEvent('pointermove'))
element.dispatchEvent(new PointerEvent('pointerup'))
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
const { element } = setup()
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
})
it('detaches every listener when the preview is removed', () => {
const { node, element, removeAnimatedPreview } = setup()
removeAnimatedPreview(node)
element.dispatchEvent(new WheelEvent('wheel'))
element.dispatchEvent(new PointerEvent('pointermove'))
element.dispatchEvent(new PointerEvent('pointerup'))
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,3 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
@@ -40,20 +39,17 @@ export function useNodeAnimatedImage() {
const { handleWheel, handlePointer, forwardEventToCanvas } =
useCanvasInteractions()
node.imgs[0].style.pointerEvents = 'none'
const controller = new AbortController()
const { signal } = controller
element.addEventListener('wheel', handleWheel, { signal })
element.addEventListener('pointermove', handlePointer, { signal })
element.addEventListener('pointerup', handlePointer, { signal })
element.addEventListener('wheel', handleWheel)
element.addEventListener('pointermove', handlePointer)
element.addEventListener('pointerup', handlePointer)
element.addEventListener(
'pointerdown',
(e) => (e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)),
{ capture: true, signal }
(e) => {
return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)
},
true
)
widget.onRemove = useChainCallback(widget.onRemove, () => {
controller.abort()
})
widget.serialize = false
widget.serializeValue = () => undefined
}

View File

@@ -1,93 +0,0 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { useNodeVideo } from '@/composables/node/useNodeImage'
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
canvasInteractionsMock: {
handleWheel: vi.fn(),
handlePointer: vi.fn()
},
nodeOutputStoreMock: {
getNodeImageUrls: vi.fn<(node: unknown) => string[] | undefined>()
}
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: () => canvasInteractionsMock
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => nodeOutputStoreMock
}))
vi.mock('@/utils/imageUtil', () => ({
fitDimensionsToNodeWidth: () => ({ minHeight: 256, minWidth: 256 })
}))
describe('useNodeVideo', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
async function setup() {
vi.clearAllMocks()
vi.useFakeTimers()
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
const node = createMockMediaNode({
size: [400, 400],
graph: { setDirtyCanvas: vi.fn() }
})
const createdVideos: HTMLVideoElement[] = []
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = realCreateElement(tag, opts)
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
return el
}
)
const { showPreview } = useNodeVideo(node)
showPreview()
// happy-dom does not auto-fire onloadeddata for src assignment, so we
// manually trigger it, then drain the resulting promise chain.
const video = createdVideos[0]
video.onloadeddata?.(new Event('loadeddata'))
await vi.runAllTimersAsync()
onTestFinished(() => {
node.widgets[0]?.onRemove?.()
})
return { node, video }
}
it('creates a video-preview widget and forwards canvas events while alive', async () => {
const { node, video } = await setup()
expect(node.widgets[0]?.name).toBe('video-preview')
video.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(2)
})
it('detaches every listener when the widget is removed', async () => {
const { node, video } = await setup()
node.widgets[0]?.onRemove?.()
video.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,3 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -152,6 +151,11 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
const video = document.createElement('video')
Object.assign(video, VIDEO_DEFAULT_OPTIONS)
// Add event listeners for canvas interactions
video.addEventListener('wheel', handleWheel)
video.addEventListener('pointermove', handlePointer)
video.addEventListener('pointerdown', handlePointer)
video.onloadeddata = () => {
setMinDimensions(video)
resolve(video)
@@ -172,16 +176,6 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
minHeight,
minWidth
})
const controller = new AbortController()
const { signal } = controller
container.addEventListener('wheel', handleWheel, { signal })
container.addEventListener('pointermove', handlePointer, { signal })
container.addEventListener('pointerdown', handlePointer, { signal })
widget.onRemove = useChainCallback(widget.onRemove, () => {
controller.abort()
})
}
}

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