Merge branch 'main' into austin/subgraph-fixtures

This commit is contained in:
Alexander Brown
2026-05-02 00:16:52 -07:00
committed by GitHub
266 changed files with 16646 additions and 4203 deletions

View File

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

23
.github/actions/ashby-pull/action.yaml vendored Normal file
View File

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

View File

@@ -0,0 +1,87 @@
# 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,17 +12,30 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -14,16 +14,29 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -68,15 +81,17 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -3,10 +3,8 @@ 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 }}
@@ -16,12 +14,24 @@ 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:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,9 +16,22 @@ permissions:
contents: read
jobs:
collect:
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' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

View File

@@ -4,7 +4,6 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
merge_group:
@@ -15,36 +14,20 @@ 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: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- 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'
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
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
@@ -82,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -140,7 +123,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -194,7 +177,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
@@ -233,7 +216,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: |
@@ -251,7 +234,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
}}
@@ -278,7 +261,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,10 +8,29 @@ 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
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')
permissions:
pull-requests: write
steps:
@@ -30,8 +49,13 @@ jobs:
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
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')
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -67,8 +91,15 @@ 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-'))
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'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -107,9 +138,15 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [storybook-build]
needs: [changes, storybook-build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
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')
permissions:
pull-requests: write
contents: read

View File

@@ -4,10 +4,8 @@ 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:
@@ -15,7 +13,20 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
test:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:

View File

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

View File

@@ -4,23 +4,29 @@ 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:
@@ -30,4 +36,7 @@ jobs:
uses: ./.github/actions/setup-frontend
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build

View File

@@ -3,25 +3,29 @@ 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
@@ -45,6 +49,8 @@ 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
@@ -161,7 +167,11 @@ 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.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
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')
runs-on: ubuntu-latest
permissions:
pull-requests: write

View File

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

View File

@@ -18,6 +18,7 @@ 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.
@@ -86,6 +87,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
@@ -137,7 +140,10 @@ jobs:
name: 'Update Website Screenshots'
})
} catch (e) {
// Label may already be removed
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
}
post-starting-comment:

59
.github/workflows/release-website.yaml vendored Normal file
View File

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

View File

@@ -1,5 +1,13 @@
#!/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,6 +113,31 @@ 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,6 +26,7 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

@@ -1,4 +1,33 @@
# 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,13 +1,12 @@
<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
@@ -17,30 +16,6 @@ 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>()
@@ -55,10 +30,6 @@ useHeroAnimation({
video: formRef,
parallax: false
})
function handleSubmit() {
// TODO: implement form submission
}
</script>
<template>
@@ -105,160 +76,7 @@ function handleSubmit() {
<!-- Right column: form -->
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
<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>
<HubspotFormEmbed :locale />
</div>
</section>
</template>

View File

@@ -0,0 +1,126 @@
<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,82 +3298,13 @@ const translations = {
en: 'Find your answer here',
'zh-CN': '在这里找到答案'
},
'contact.form.firstName': {
en: 'First name',
'zh-CN': ''
'contact.form.embedLoadErrorPrefix': {
en: 'Unable to load the contact form. Email us at',
'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'
'contact.form.embedLoadErrorSuffix': {
en: "and we'll route your request.",
'zh-CN': '我们会为您处理请求。'
},
'customers.story.whatsNext': {

View File

@@ -133,9 +133,15 @@ 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

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

@@ -0,0 +1,36 @@
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,6 +2,9 @@ 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' }
@@ -25,3 +28,17 @@ export function formatStarCount(count: number): string {
}
return count.toString()
}
function readGitHubStarsOverride(): number | undefined {
const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
if (rawCount === undefined || rawCount === '') return undefined
const count = Number(rawCount)
if (!Number.isSafeInteger(count) || count < 0) {
throw new Error(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
}
return count
}

View File

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

View File

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

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

View File

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

View File

@@ -1,284 +0,0 @@
{
"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 { Mouse } from '@playwright/test'
import type { Locator, Mouse } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
@@ -72,6 +72,22 @@ 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,6 +30,13 @@ 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.
@@ -122,10 +129,9 @@ export class VueNodeHelpers {
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
* Resolve the data-node-id of the first rendered node matching the title.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
async getNodeIdByTitle(title: string): Promise<string> {
const node = this.getNodeByTitle(title).first()
await node.waitFor({ state: 'visible' })
@@ -136,6 +142,15 @@ 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,6 +17,9 @@ 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
@@ -28,6 +31,11 @@ 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

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

View File

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

@@ -264,11 +264,39 @@ export class CanvasHelper {
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
async disconnectEdge(
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
): Promise<void> {
const { modifiers = [] } = options
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
}
async middleClick(position: Position): Promise<void> {
await this.mouseClickAt(position, { button: 'middle' })
}
async dblclickGroupTitle(title: string): Promise<void> {
const clientPos = await this.page.evaluate((targetTitle) => {
const groups = window.app!.canvas.graph?.groups ?? []
const group = groups.find(
(g: { title: string }) => g.title === targetTitle
)
if (!group) return null
const cx = group.pos[0] + group.size[0] / 2
const cy = group.pos[1] + group.titleHeight / 2
return window.app!.canvasPosToClientPos([cx, cy])
}, title)
if (!clientPos) throw new Error(`Group "${title}" not found`)
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
await nextFrame(this.page)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {

View File

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

View File

@@ -1,9 +1,35 @@
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.
*/
@@ -16,13 +42,23 @@ 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.
@@ -39,7 +75,7 @@ export class ExecutionHelper {
})
await this.page.route(
'**/api/prompt',
PROMPT_ROUTE_PATTERN,
async (route) => {
await route.fulfill({
status: 200,
@@ -60,6 +96,31 @@ 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.
@@ -89,12 +150,12 @@ export class ExecutionHelper {
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.ws.send(Buffer.from(buf))
this.requireWs().send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
@@ -104,7 +165,7 @@ export class ExecutionHelper {
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
@@ -118,7 +179,7 @@ export class ExecutionHelper {
nodeId: string,
output: Record<string, unknown>
): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'executed',
data: {
@@ -133,7 +194,7 @@ export class ExecutionHelper {
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
@@ -143,7 +204,7 @@ export class ExecutionHelper {
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'execution_error',
data: {
@@ -161,7 +222,7 @@ export class ExecutionHelper {
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
@@ -201,7 +262,7 @@ export class ExecutionHelper {
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.ws.send(
this.requireWs().send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }

View File

@@ -89,7 +89,8 @@ export const TestIds = {
subscribeButton: 'topbar-subscribe-button',
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more'
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -211,7 +212,9 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',
notificationBanner: 'queue-notification-banner'
},
errors: {
imageLoadError: 'error-loading-image',
@@ -262,6 +265,9 @@ 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

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
@@ -169,6 +170,36 @@ class NodeSlotReference {
[this.type, this.node.id, this.index] as const
)
}
async getLink(): Promise<SerialisableLLink | null> {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const graph = window.app!.canvas.graph!
const node = graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const linkId =
type === 'input'
? node.inputs[index].link
: (node.outputs[index].links ?? [])[0]
if (linkId == null) return null
const link =
graph.links instanceof Map
? graph.links.get(linkId)
: graph.links[linkId]
if (!link) return null
return {
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
target_id: link.target_id,
target_slot: link.target_slot,
type: link.type,
parentId: link.parentId
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {

View File

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

View File

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

View File

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

View File

@@ -282,6 +282,57 @@ 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,5 +267,65 @@ 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,4 +125,151 @@ 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

@@ -369,5 +369,32 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
})
test('Follow-cursor disabled places node without ghost mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
false
)
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await expect(
comfyPage.page.locator('[data-node-id][data-ghost]')
).toHaveCount(0)
})
})
})

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/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(

View File

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

View File

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

@@ -0,0 +1,164 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Mirrors BANNER_DISMISS_DELAY_MS in src/composables/queue/useQueueNotificationBanners.ts.
// Duplicated here to avoid pulling production source (and its litegraph
// transitive deps) into the Playwright TS loader.
const BANNER_DISMISS_DELAY_MS = 4000
const BANNER_ASSERT_TIMEOUT_MS = BANNER_DISMISS_DELAY_MS + 2000
const REQUEST_ID_PRIMARY = 1
const REQUEST_ID_SECONDARY = 2
const REQUEST_ID_MISMATCH = 999
let nextRequestId = 1000
const newRequestId = () => nextRequestId++
function bannerLocator(page: Page) {
return page.getByTestId(TestIds.queue.notificationBanner)
}
type DispatchOpts = { batchCount?: number; requestId?: number }
function dispatchPromptQueueing(page: Page, opts: DispatchOpts = {}) {
return page.evaluate(
([batchCount, requestId]) => {
window.app!.api.dispatchCustomEvent('promptQueueing', {
batchCount,
requestId
})
},
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
)
}
function dispatchPromptQueued(page: Page, opts: DispatchOpts = {}) {
return page.evaluate(
([batchCount, requestId]) => {
window.app!.api.dispatchCustomEvent('promptQueued', {
number: 0,
batchCount,
requestId
})
},
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
)
}
test.describe('Queue notification banners', { tag: ['@ui'] }, () => {
test.describe('Queuing lifecycle', () => {
test('promptQueueing event shows a queueing banner', async ({
comfyPage
}) => {
await dispatchPromptQueueing(comfyPage.page)
const banner = bannerLocator(comfyPage.page)
await expect(banner).toBeVisible()
await expect(banner).toContainText('queuing')
})
test('promptQueued upgrades a pending banner to queued', async ({
comfyPage
}) => {
await dispatchPromptQueueing(comfyPage.page, {
batchCount: 1,
requestId: REQUEST_ID_PRIMARY
})
const banner = bannerLocator(comfyPage.page)
await expect(banner).toContainText('queuing')
await dispatchPromptQueued(comfyPage.page, {
batchCount: 1,
requestId: REQUEST_ID_PRIMARY
})
await expect(banner).toContainText('queued')
})
test('promptQueued with batch count > 1 shows plural text', async ({
comfyPage
}) => {
await dispatchPromptQueued(comfyPage.page, { batchCount: 3 })
const banner = bannerLocator(comfyPage.page)
await expect(banner).toBeVisible()
await expect(banner).toContainText('3')
await expect(banner).toContainText('jobs added to queue')
})
test('promptQueued with mismatched requestId enqueues a separate queued banner', async ({
comfyPage
}) => {
await dispatchPromptQueueing(comfyPage.page, {
batchCount: 1,
requestId: REQUEST_ID_PRIMARY
})
const banner = bannerLocator(comfyPage.page)
await expect(banner).toContainText('queuing')
await dispatchPromptQueued(comfyPage.page, {
batchCount: 1,
requestId: REQUEST_ID_MISMATCH
})
// Pending banner is not upgraded — still shows "queuing".
await expect(banner).toContainText('queuing')
// After the pending banner auto-dismisses, the queued banner appears.
await expect(banner).toContainText('queued', {
timeout: BANNER_ASSERT_TIMEOUT_MS
})
})
})
test.describe('Auto-dismiss', () => {
test('Banner auto-dismisses after timeout', async ({ comfyPage }) => {
await dispatchPromptQueued(comfyPage.page)
const banner = bannerLocator(comfyPage.page)
await expect(banner).toBeVisible()
await expect(banner).toBeHidden({ timeout: BANNER_ASSERT_TIMEOUT_MS })
})
})
test.describe('Notification queue (FIFO)', () => {
test('Second notification shows after first auto-dismisses', async ({
comfyPage
}) => {
await dispatchPromptQueued(comfyPage.page, {
batchCount: 1,
requestId: REQUEST_ID_PRIMARY
})
await dispatchPromptQueued(comfyPage.page, {
batchCount: 2,
requestId: REQUEST_ID_SECONDARY
})
const banner = bannerLocator(comfyPage.page)
await expect(banner).toContainText('Job queued')
await expect(banner).toContainText('2 jobs added to queue', {
timeout: BANNER_ASSERT_TIMEOUT_MS
})
})
})
test.describe('Direct queued event (no pending predecessor)', () => {
test('promptQueued without prior queueing shows queued banner directly', async ({
comfyPage
}) => {
await dispatchPromptQueued(comfyPage.page, {
batchCount: 1,
requestId: REQUEST_ID_PRIMARY
})
const banner = bannerLocator(comfyPage.page)
await expect(banner).toBeVisible()
await expect(banner).toContainText('queued')
})
})
})

View File

@@ -42,8 +42,11 @@ 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'
'selection-toolbox-multiple-nodes-border.png',
{ maxDiffPixels: 100 }
)
})

View File

@@ -1012,3 +1012,42 @@ test.describe('Assets sidebar - media type filter', () => {
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
})
test.describe('Assets sidebar - drag and drop', () => {
test('Dragging outputs from assets skips upload', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job',
preview_output: {
filename: `test.png`,
type: 'temp',
nodeId: '1',
mediaType: 'images'
}
})
])
await comfyPage.page.route('**/upload/image', (route) => {
expect(true, 'file is not uploaded').toBe(false)
return route.fulfill({ status: 405 })
})
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('.')
const { assetsTab } = comfyPage.menu
await assetsTab.open()
await assetsTab.waitForAssets()
await expect(assetsTab.assetCards).toHaveCount(1)
const targetPosition =
(await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')) ??
undefined
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const fileComboWidget = await nodes[0].getWidget(0)
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
})
})

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

View File

@@ -14,8 +14,6 @@ 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,
@@ -42,31 +40,6 @@ 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
@@ -525,29 +498,4 @@ 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,10 +121,7 @@ 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 comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
})
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,9 +1,25 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
comfyPageFixture
} 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 ({
@@ -11,24 +27,202 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// 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)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID)
).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()
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
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)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,6 +2,10 @@ 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 }) => {
@@ -20,15 +24,11 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
function getNode(comfyPage: ComfyPage) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
function getWidgets(comfyPage: 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,20 +58,41 @@ 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
}) => {
@@ -92,6 +113,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

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

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

@@ -0,0 +1,205 @@
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,6 +257,8 @@ 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,9 +30,42 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
## Don't Mock `vue-i18n` — Use a Real Plugin
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
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.
## Mock Patterns

View File

@@ -55,7 +55,9 @@ 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'
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.12",
"version": "1.44.15",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -83,6 +83,7 @@
"@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",
@@ -113,6 +114,7 @@
"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,6 +27,23 @@
--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,10 +41,6 @@
--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;
@@ -53,7 +49,6 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9c9eab;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -208,7 +203,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-ash-500);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-surface-hovered: var(--color-smoke-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
@@ -227,7 +222,7 @@
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
--text-secondary: var(--color-ash-500);
--text-secondary: var(--color-smoke-800);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
@@ -264,7 +259,7 @@
--secondary-background-selected
);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
@@ -344,19 +339,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-slate-300);
--node-component-header-icon: var(--color-smoke-800);
--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-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-slot-text: var(--color-smoke-700);
--node-component-surface-highlight: var(--color-smoke-800);
--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-slate-300);
--node-component-tooltip-border: var(--color-charcoal-200);
--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);
@@ -374,7 +369,7 @@
);
--color-interface-panel-job-progress-border: var(--base-foreground);
--text-secondary: var(--color-slate-100);
--text-secondary: var(--color-smoke-700);
--text-primary: var(--color-white);
--input-surface: rgb(130 130 130 / 0.1);
@@ -414,7 +409,7 @@
--component-node-widget-background-disabled: var(
--color-alpha-charcoal-600-30
);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);

View File

@@ -4062,6 +4062,22 @@ 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;
@@ -14490,6 +14506,16 @@ 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
@@ -30338,10 +30364,7 @@ export interface operations {
};
seedanceGetAsset: {
parameters: {
query?: {
/** @description BytePlus project name. Defaults to "default" if omitted. Must match the ProjectName used at create time. */
project_name?: string;
};
query?: never;
header?: never;
path: {
/** @description BytePlus-issued asset id returned by seedanceCreateAsset */
@@ -30371,6 +30394,39 @@ 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,7 +8,10 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
workers: 1,
use: {
trace: 'on',
video: 'on'
video: 'on',
launchOptions: {
slowMo: Number(process.env.SLOW_MO) || 0
}
}
}
: {

41
pnpm-lock.yaml generated
View File

@@ -162,6 +162,9 @@ 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
@@ -360,6 +363,9 @@ 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
@@ -497,6 +503,9 @@ 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))
@@ -587,6 +596,9 @@ 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)
@@ -949,6 +961,9 @@ 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)
@@ -4721,6 +4736,11 @@ 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:
@@ -9593,6 +9613,11 @@ 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==}
@@ -14038,6 +14063,14 @@ 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
@@ -14156,7 +14189,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@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: 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/utils@3.2.4':
dependencies:
@@ -20051,6 +20084,12 @@ 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,6 +55,7 @@ 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
@@ -121,6 +122,7 @@ 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

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

View File

@@ -153,17 +153,15 @@ function nodeToNodeData(node: LGraphNode) {
}
}
async function handleDragDrop(e: DragEvent) {
for (const { nodeData } of mappedSelections.value) {
if (!nodeData?.onDragOver?.(e)) continue
const rawResult = nodeData?.onDragDrop?.(e)
if (rawResult === false) continue
e.stopPropagation()
e.preventDefault()
if ((await rawResult) === true) return
async function handleDragDrop() {
const onDragDrop = async (e: DragEvent) => {
for (const { nodeData } of mappedSelections.value)
if (nodeData?.onDragOver?.(e) && (await nodeData.onDragDrop?.(e)))
return true
return false
}
app.dragOverNode = { id: -1, onDragDrop }
}
defineExpose({ handleDragDrop })

View File

@@ -1,7 +1,5 @@
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'
@@ -17,10 +15,6 @@ describe('EditableText', () => {
const user = userEvent.setup()
render(EditableText, {
global: {
plugins: [PrimeVue],
components: { InputText }
},
props: {
...props,
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),

View File

@@ -1,24 +1,18 @@
<template>
<div class="editable-text">
<div class="editable-text inline">
<component :is="labelType" v-if="!isEditing" :class="labelClass">
{{ modelValue }}
</component>
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<InputText
<Input
v-else
ref="inputRef"
v-model:model-value="inputValue"
v-model="inputValue"
v-focus
type="text"
size="small"
fluid
:pt="{
root: {
onBlur: finishEditing,
...inputAttrs
}
}"
@keydown.enter.capture.stop="blurInputElement"
class="h-full rounded-none p-0 focus-visible:ring-0"
v-bind="inputAttrs"
@blur="finishEditing"
@keydown.enter.capture.stop="inputRef?.blur()"
@keydown.escape.capture.stop="cancelEditing"
@click.stop
@contextmenu.stop
@@ -29,9 +23,10 @@
</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,
@@ -48,30 +43,23 @@ const {
const emit = defineEmits(['edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const inputRef = ref<InstanceType<typeof Input>>()
const isCanceling = ref(false)
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
function finishEditing() {
if (!isCanceling.value) {
emit('edit', inputValue.value)
}
isCanceling.value = false
}
const cancelEditing = () => {
// Set canceling flag to prevent blur from saving
function cancelEditing() {
isCanceling.value = true
// Reset to original value
inputValue.value = modelValue
// Emit cancel event
emit('cancel')
// Blur the input to exit edit mode
blurInputElement()
inputRef.value?.blur()
}
watch(
() => isEditing,
async (newVal) => {
@@ -82,27 +70,14 @@ watch(
const fileName = inputValue.value.includes('.')
? inputValue.value.split('.').slice(0, -1).join('.')
: inputValue.value
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)
inputRef.value.setSelectionRange(0, fileName.length)
})
}
},
{ 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

@@ -0,0 +1,85 @@
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,49 +1,43 @@
<template>
<div class="prompt-dialog-content flex flex-col gap-2 pt-8">
<FloatLabel>
<InputText
<label class="flex flex-col gap-1 text-sm text-muted-foreground">
{{ message }}
<Input
ref="inputRef"
v-model="inputValue"
type="text"
:placeholder
autofocus
@keyup.enter="onConfirm"
@focus="selectAllText"
@keyup.enter="handleConfirm"
@focus="inputRef?.selectAll()"
/>
<label>{{ message }}</label>
</FloatLabel>
<Button @click="onConfirm">
</label>
<Button @click="handleConfirm">
{{ $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 props = defineProps<{
const { message, defaultValue, onConfirm, placeholder } = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(props.defaultValue)
const inputValue = ref<string>(defaultValue)
const onConfirm = () => {
props.onConfirm(inputValue.value)
function handleConfirm() {
onConfirm(inputValue.value)
useDialogStore().closeDialog()
}
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)
}
const inputRef = ref<InstanceType<typeof Input>>()
</script>

View File

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

View File

@@ -8,11 +8,6 @@
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"
>
@@ -189,7 +184,6 @@ 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

@@ -0,0 +1,153 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { ref } from 'vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
const dragState = vi.hoisted(() => ({
isDragging: null as Ref<boolean> | null,
dragMessage: null as Ref<string> | null,
handleDragOver: vi.fn(),
handleDragLeave: vi.fn(),
handleDrop: vi.fn(),
capturedOptions: null as {
onModelDrop?: (file: File) => Promise<void>
disabled?: { value?: boolean } | boolean
} | null
}))
vi.mock('@/composables/useLoad3dDrag', () => ({
useLoad3dDrag: (options: unknown) => {
dragState.capturedOptions = options as typeof dragState.capturedOptions
return {
isDragging: dragState.isDragging!,
dragMessage: dragState.dragMessage!,
handleDragOver: dragState.handleDragOver,
handleDragLeave: dragState.handleDragLeave,
handleDrop: dragState.handleDrop
}
}
}))
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
default: {
name: 'LoadingOverlayStub',
props: ['loading', 'loadingMessage'],
template: `
<div data-testid="loading-overlay">
<span v-if="loading">{{ loadingMessage }}</span>
</div>
`
}
}))
type RenderOpts = {
loading?: boolean
loadingMessage?: string
isPreview?: boolean
onModelDrop?: (file: File) => void | Promise<void>
initializeLoad3d?: (container: HTMLElement) => Promise<void>
cleanup?: () => void
}
function renderComponent(opts: RenderOpts = {}) {
const initializeLoad3d =
opts.initializeLoad3d ?? vi.fn().mockResolvedValue(undefined)
const cleanup = opts.cleanup ?? vi.fn()
const utils = render(Load3DScene, {
props: {
initializeLoad3d,
cleanup,
loading: opts.loading ?? false,
loadingMessage: opts.loadingMessage ?? '',
onModelDrop: opts.onModelDrop,
isPreview: opts.isPreview ?? false
}
})
return { ...utils, initializeLoad3d, cleanup }
}
describe('Load3DScene', () => {
beforeEach(() => {
dragState.isDragging = ref(false)
dragState.dragMessage = ref('')
dragState.handleDragOver.mockReset()
dragState.handleDragLeave.mockReset()
dragState.handleDrop.mockReset()
dragState.capturedOptions = null
})
it('renders the loading overlay child', () => {
renderComponent()
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
})
it('forwards loading + loadingMessage props to the overlay', () => {
renderComponent({ loading: true, loadingMessage: 'Loading model…' })
expect(screen.getByText('Loading model…')).toBeInTheDocument()
})
it('calls initializeLoad3d with the container element on mount', async () => {
const initializeLoad3d = vi.fn().mockResolvedValue(undefined)
renderComponent({ initializeLoad3d })
expect(initializeLoad3d).toHaveBeenCalledOnce()
expect(initializeLoad3d.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
})
it('calls cleanup when unmounted', () => {
const cleanup = vi.fn()
const { unmount } = renderComponent({ cleanup })
unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('does not render the drag overlay when not dragging', () => {
dragState.isDragging!.value = false
dragState.dragMessage!.value = 'Drop'
renderComponent()
expect(screen.queryByText('Drop')).not.toBeInTheDocument()
})
it('renders the drag overlay with the drag message while dragging in non-preview mode', () => {
dragState.isDragging!.value = true
dragState.dragMessage!.value = 'Drop to load model'
renderComponent({ isPreview: false })
expect(screen.getByText('Drop to load model')).toBeInTheDocument()
})
it('hides the drag overlay even while dragging when in preview mode', () => {
dragState.isDragging!.value = true
dragState.dragMessage!.value = 'Drop to load model'
renderComponent({ isPreview: true })
expect(screen.queryByText('Drop to load model')).not.toBeInTheDocument()
})
it('forwards a dropped file through useLoad3dDrag to the onModelDrop prop', async () => {
const onModelDrop = vi.fn()
renderComponent({ onModelDrop })
const file = new File(['m'], 'model.glb')
await dragState.capturedOptions!.onModelDrop!(file)
expect(onModelDrop).toHaveBeenCalledWith(file)
})
it('does not throw when a file is dropped without an onModelDrop handler', async () => {
renderComponent({ onModelDrop: undefined })
const file = new File(['m'], 'model.glb')
await expect(
dragState.capturedOptions!.onModelDrop!(file)
).resolves.toBeUndefined()
})
})

View File

@@ -6,6 +6,7 @@ import { createI18n } from 'vue-i18n'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
class NoopMutationObserver {
observe() {}
@@ -129,7 +130,7 @@ type RenderOptions = {
dragOverrides?: Partial<ReturnType<typeof buildDragStub>>
}
const MOCK_NODE = { id: 'node-1', type: 'Load3D' } as unknown as LGraphNode
const MOCK_NODE = createMockLGraphNode({ id: 'node-1', type: 'Load3D' })
async function renderViewerContent(options: RenderOptions = {}) {
const viewerStub = buildViewerStub()

View File

@@ -0,0 +1,205 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { playPause: 'Play / pause' } } }
})
type Animation = { name: string; index: number }
type RenderOpts = {
animations?: Animation[]
playing?: boolean
selectedSpeed?: number
selectedAnimation?: number
animationProgress?: number
animationDuration?: number
onSeek?: (progress: number) => void
}
function renderComponent(opts: RenderOpts = {}) {
const animations = ref<Animation[]>(opts.animations ?? [])
const playing = ref<boolean>(opts.playing ?? false)
const selectedSpeed = ref<number>(opts.selectedSpeed ?? 1)
const selectedAnimation = ref<number>(opts.selectedAnimation ?? 0)
const animationProgress = ref<number>(opts.animationProgress ?? 0)
const animationDuration = ref<number>(opts.animationDuration ?? 10)
const utils = render(AnimationControls, {
props: {
animations: animations.value,
'onUpdate:animations': (v: Animation[] | undefined) => {
if (v) animations.value = v
},
playing: playing.value,
'onUpdate:playing': (v: boolean | undefined) => {
if (v !== undefined) playing.value = v
},
selectedSpeed: selectedSpeed.value,
'onUpdate:selectedSpeed': (v: number | undefined) => {
if (v !== undefined) selectedSpeed.value = v
},
selectedAnimation: selectedAnimation.value,
'onUpdate:selectedAnimation': (v: number | undefined) => {
if (v !== undefined) selectedAnimation.value = v
},
animationProgress: animationProgress.value,
'onUpdate:animationProgress': (v: number | undefined) => {
if (v !== undefined) animationProgress.value = v
},
animationDuration: animationDuration.value,
'onUpdate:animationDuration': (v: number | undefined) => {
if (v !== undefined) animationDuration.value = v
},
onSeek: opts.onSeek
},
global: { plugins: [i18n] }
})
return {
...utils,
animations,
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
user: userEvent.setup()
}
}
describe('AnimationControls', () => {
it('renders nothing when the animation list is empty', () => {
renderComponent({ animations: [] })
expect(
screen.queryByRole('button', { name: 'Play / pause' })
).not.toBeInTheDocument()
})
it('renders the play / speed / track / progress widgets when animations are present', () => {
renderComponent({
animations: [
{ name: 'idle', index: 0 },
{ name: 'walk', index: 1 }
]
})
expect(
screen.getByRole('button', { name: 'Play / pause' })
).toBeInTheDocument()
expect(screen.getAllByRole('combobox')).toHaveLength(2)
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('flips playing to true via v-model when starting from a paused state', async () => {
const { user, playing } = renderComponent({
animations: [{ name: 'idle', index: 0 }],
playing: false
})
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
expect(playing.value).toBe(true)
})
it('flips playing to false via v-model when starting from a playing state', async () => {
const { user, playing } = renderComponent({
animations: [{ name: 'idle', index: 0 }],
playing: true
})
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
expect(playing.value).toBe(false)
})
it('updates animationProgress and emits seek with the new progress when the slider moves', () => {
const onSeek = vi.fn()
const { animationProgress } = renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationProgress: 0,
onSeek
})
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '37.5'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(animationProgress.value).toBe(37.5)
expect(onSeek).toHaveBeenCalledWith(37.5)
})
it('formats the time display under one minute as Ns', () => {
renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationDuration: 30,
animationProgress: 50 // half of 30s = 15s
})
expect(screen.getByText('15.0s / 30.0s')).toBeInTheDocument()
})
it('formats the time display over one minute as M:SS.S', () => {
renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationDuration: 90,
animationProgress: 50 // half of 90s = 45s, total 1:30.0
})
expect(screen.getByText('45.0s / 1:30.0')).toBeInTheDocument()
})
it('shows 0s for currentTime when animationDuration is 0', () => {
renderComponent({
animations: [{ name: 'idle', index: 0 }],
animationDuration: 0,
animationProgress: 50
})
expect(screen.getByText('0.0s / 0.0s')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
default: {
name: 'PopupSlider',
props: ['tooltipText', 'modelValue'],
template: '<div data-testid="popup-slider">{{ tooltipText }}</div>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { load3d: { switchCamera: 'Switch camera', fov: 'FOV' } }
}
})
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
const fov = ref<number>(initial.fov ?? 75)
const utils = render(CameraControls, {
props: {
cameraType: cameraType.value,
'onUpdate:cameraType': (v: CameraType | undefined) => {
if (v) cameraType.value = v
},
fov: fov.value,
'onUpdate:fov': (v: number | undefined) => {
if (v !== undefined) fov.value = v
}
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, cameraType, fov, user: userEvent.setup() }
}
describe('CameraControls', () => {
it('renders the switch-camera button', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Switch camera' })
).toBeInTheDocument()
})
it('shows the FOV PopupSlider only for the perspective camera', () => {
renderComponent({ type: 'perspective' })
expect(screen.getByTestId('popup-slider')).toBeInTheDocument()
})
it('hides the FOV PopupSlider for the orthographic camera', () => {
renderComponent({ type: 'orthographic' })
expect(screen.queryByTestId('popup-slider')).not.toBeInTheDocument()
})
it('toggles cameraType from perspective to orthographic when the button is clicked', async () => {
const { user, cameraType } = renderComponent({ type: 'perspective' })
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
expect(cameraType.value).toBe('orthographic')
})
it('toggles cameraType from orthographic to perspective when the button is clicked', async () => {
const { user, cameraType } = renderComponent({ type: 'orthographic' })
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
expect(cameraType.value).toBe('perspective')
})
})

View File

@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { load3d: { exportModel: 'Export model' } }
}
})
function renderComponent(onExportModel?: (format: string) => void) {
const utils = render(ExportControls, {
props: { onExportModel },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, user: userEvent.setup() }
}
describe('ExportControls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('renders the trigger button without exposing the format list initially', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Export model' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'GLB' })
).not.toBeInTheDocument()
})
it('reveals all three export format buttons when the trigger is clicked', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Export model' }))
for (const label of ['GLB', 'OBJ', 'STL']) {
expect(screen.getByRole('button', { name: label })).toBeVisible()
}
})
it('emits exportModel with the chosen format and hides the popup', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel)
await user.click(screen.getByRole('button', { name: 'Export model' }))
await user.click(screen.getByRole('button', { name: 'OBJ' }))
expect(onExportModel).toHaveBeenCalledWith('obj')
expect(
screen.queryByRole('button', { name: 'GLB' })
).not.toBeInTheDocument()
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'GLB' })).toBeVisible()
await user.click(document.body)
expect(
screen.queryByRole('button', { name: 'GLB' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,197 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden file input has no role/label, queried by selector */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
const addAlert = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
hdri: {
label: 'HDRI',
uploadFile: 'Upload HDRI',
changeFile: 'Change HDRI',
showAsBackground: 'Show as background',
removeFile: 'Remove HDRI'
}
},
toastMessages: { unsupportedHDRIFormat: 'Unsupported HDRI format' }
}
}
})
const defaultConfig: HDRIConfig = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
type RenderOpts = {
config?: HDRIConfig
hasBackgroundImage?: boolean
onUpdateHdriFile?: (file: File | null) => void
}
function renderComponent(opts: RenderOpts = {}) {
const config = ref<HDRIConfig>(opts.config ?? { ...defaultConfig })
const utils = render(HDRIControls, {
props: {
hdriConfig: config.value,
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
if (v) config.value = v
},
hasBackgroundImage: opts.hasBackgroundImage ?? false,
onUpdateHdriFile: opts.onUpdateHdriFile
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, config, user: userEvent.setup() }
}
describe('HDRIControls', () => {
beforeEach(() => {
addAlert.mockClear()
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('initial render', () => {
it('renders the upload button when no HDRI is loaded', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Upload HDRI' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'HDRI' })
).not.toBeInTheDocument()
})
it('renders the change / toggle / show-as-bg / remove buttons when an HDRI is loaded', () => {
renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
expect(
screen.getByRole('button', { name: 'Change HDRI' })
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'HDRI' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Show as background' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Remove HDRI' })
).toBeInTheDocument()
})
it('hides the entire control when a background image is set and no HDRI is loaded', () => {
const { container } = renderComponent({
hasBackgroundImage: true,
config: { ...defaultConfig, hdriPath: '' }
})
expect(container.querySelector('button')).toBeNull()
})
it('still renders when a background image is set but an HDRI is loaded', () => {
renderComponent({
hasBackgroundImage: true,
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
expect(
screen.getByRole('button', { name: 'Change HDRI' })
).toBeInTheDocument()
})
})
describe('toggle buttons', () => {
it('flips enabled in the v-model when the HDRI button is clicked', async () => {
const { user, config } = renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
await user.click(screen.getByRole('button', { name: 'HDRI' }))
expect(config.value.enabled).toBe(true)
})
it('flips showAsBackground in the v-model when the show-as-background button is clicked', async () => {
const { user, config } = renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
})
await user.click(
screen.getByRole('button', { name: 'Show as background' })
)
expect(config.value.showAsBackground).toBe(true)
})
})
describe('file events', () => {
it('emits updateHdriFile(null) when the remove button is clicked', async () => {
const onUpdateHdriFile = vi.fn()
const { user } = renderComponent({
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' },
onUpdateHdriFile
})
await user.click(screen.getByRole('button', { name: 'Remove HDRI' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('emits updateHdriFile with the picked file when its extension is supported', async () => {
const onUpdateHdriFile = vi.fn()
const { container } = renderComponent({ onUpdateHdriFile })
const fileInput = container.querySelector(
'input[type="file"]'
) as HTMLInputElement
const file = new File(['hdri-data'], 'sky.hdr', {
type: 'application/octet-stream'
})
Object.defineProperty(fileInput, 'files', { value: [file] })
fileInput.dispatchEvent(new Event('change'))
expect(onUpdateHdriFile).toHaveBeenCalledWith(file)
expect(addAlert).not.toHaveBeenCalled()
})
it('rejects unsupported file extensions with a toast and no emit', async () => {
const onUpdateHdriFile = vi.fn()
const { container } = renderComponent({ onUpdateHdriFile })
const fileInput = container.querySelector(
'input[type="file"]'
) as HTMLInputElement
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(fileInput, 'files', { value: [file] })
fileInput.dispatchEvent(new Event('change'))
expect(onUpdateHdriFile).not.toHaveBeenCalled()
expect(addAlert).toHaveBeenCalledWith('Unsupported HDRI format')
})
})
})

View File

@@ -0,0 +1,193 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import type {
HDRIConfig,
MaterialMode
} from '@/extensions/core/load3d/interfaces'
const settingValues: Record<string, unknown> = {
'Comfy.Load3D.LightIntensityMaximum': 10,
'Comfy.Load3D.LightIntensityMinimum': 1,
'Comfy.Load3D.LightAdjustmentIncrement': 0.5
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settingValues[key]
})
}))
vi.mock('@/composables/useDismissableOverlay', () => ({
useDismissableOverlay: vi.fn()
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { load3d: { lightIntensity: 'Light intensity' } } }
})
type RenderOpts = {
lightIntensity?: number
materialMode?: MaterialMode
hdriConfig?: HDRIConfig
embedded?: boolean
}
function renderComponent(opts: RenderOpts = {}) {
const lightIntensity = ref<number>(opts.lightIntensity ?? 5)
const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original')
const hdriConfig = ref<HDRIConfig | undefined>(opts.hdriConfig)
const utils = render(LightControls, {
props: {
lightIntensity: lightIntensity.value,
'onUpdate:lightIntensity': (v: number | undefined) => {
if (v !== undefined) lightIntensity.value = v
},
materialMode: materialMode.value,
'onUpdate:materialMode': (v: MaterialMode | undefined) => {
if (v) materialMode.value = v
},
hdriConfig: hdriConfig.value,
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
hdriConfig.value = v
},
embedded: opts.embedded ?? false
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return {
...utils,
lightIntensity,
hdriConfig,
user: userEvent.setup()
}
}
describe('LightControls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
describe('material mode gating', () => {
it('renders the intensity control when materialMode is original', () => {
renderComponent({ materialMode: 'original' })
expect(
screen.getByRole('button', { name: 'Light intensity' })
).toBeInTheDocument()
})
it.each(['normal', 'wireframe'] as const)(
'hides the intensity control when materialMode is %s',
(mode) => {
renderComponent({ materialMode: mode })
expect(
screen.queryByRole('button', { name: 'Light intensity' })
).not.toBeInTheDocument()
}
)
})
describe('default (non-HDRI) mode', () => {
it('feeds the slider with the setting-store min / max / step', async () => {
const { user } = renderComponent({ lightIntensity: 5 })
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
expect(slider.min).toBe('1')
expect(slider.max).toBe('10')
expect(slider.step).toBe('0.5')
})
it('updates lightIntensity v-model when the slider changes', async () => {
const { user, lightIntensity } = renderComponent({ lightIntensity: 5 })
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '7.5'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(lightIntensity.value).toBe(7.5)
})
})
describe('HDRI active mode', () => {
const hdriConfig: HDRIConfig = {
enabled: true,
hdriPath: '/api/hdri/test.hdr',
showAsBackground: false,
intensity: 2
}
it('reads the slider min / max / step from the HDRI range (0..5 step 0.1)', async () => {
const { user } = renderComponent({ hdriConfig })
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
expect(slider.min).toBe('0')
expect(slider.max).toBe('5')
expect(slider.step).toBe('0.1')
})
it('writes back to hdriConfig.intensity instead of lightIntensity when the slider changes', async () => {
const {
user,
lightIntensity,
hdriConfig: cfg
} = renderComponent({
lightIntensity: 5,
hdriConfig
})
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '3.5'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(cfg.value?.intensity).toBe(3.5)
expect(lightIntensity.value).toBe(5) // unchanged
})
})
describe('embedded mode', () => {
it('renders the slider inline without the trigger button when embedded is true', () => {
renderComponent({ embedded: true })
expect(
screen.queryByRole('button', { name: 'Light intensity' })
).not.toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,185 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
upDirection: 'Up direction',
materialMode: 'Material mode',
showSkeleton: 'Show skeleton',
materialModes: {
original: 'Original',
normal: 'Normal',
wireframe: 'Wireframe',
pointCloud: 'Point cloud',
depth: 'Depth'
}
}
}
}
})
type RenderOpts = {
upDirection?: UpDirection
materialMode?: MaterialMode
showSkeleton?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}
function renderComponent(opts: RenderOpts = {}) {
const upDirection = ref<UpDirection>(opts.upDirection ?? 'original')
const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original')
const showSkeleton = ref<boolean>(opts.showSkeleton ?? false)
const utils = render(ModelControls, {
props: {
upDirection: upDirection.value,
'onUpdate:upDirection': (v: UpDirection | undefined) => {
if (v) upDirection.value = v
},
materialMode: materialMode.value,
'onUpdate:materialMode': (v: MaterialMode | undefined) => {
if (v) materialMode.value = v
},
showSkeleton: showSkeleton.value,
'onUpdate:showSkeleton': (v: boolean | undefined) => {
if (v !== undefined) showSkeleton.value = v
},
materialModes: opts.materialModes ?? ['original', 'normal', 'wireframe'],
hasSkeleton: opts.hasSkeleton ?? false
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return {
...utils,
upDirection,
materialMode,
showSkeleton,
user: userEvent.setup()
}
}
describe('ModelControls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
describe('up direction', () => {
it('renders the up-direction trigger and opens the popup with all 7 directions', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Up direction' }))
for (const label of ['ORIGINAL', '-X', '+X', '-Y', '+Y', '-Z', '+Z']) {
expect(screen.getByRole('button', { name: label })).toBeVisible()
}
})
it('updates upDirection v-model when a direction is selected', async () => {
const { user, upDirection } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Up direction' }))
await user.click(screen.getByRole('button', { name: '+X' }))
expect(upDirection.value).toBe('+x')
})
})
describe('material mode', () => {
it('renders the material-mode trigger when materialModes is non-empty', () => {
renderComponent({ materialModes: ['original', 'normal'] })
expect(
screen.getByRole('button', { name: 'Material mode' })
).toBeInTheDocument()
})
it('hides the material-mode trigger when materialModes is empty', () => {
renderComponent({ materialModes: [] })
expect(
screen.queryByRole('button', { name: 'Material mode' })
).not.toBeInTheDocument()
})
it('renders one popup option per entry in materialModes', async () => {
const { user } = renderComponent({
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
})
await user.click(screen.getByRole('button', { name: 'Material mode' }))
expect(screen.getByRole('button', { name: 'Original' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Point cloud' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Normal' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Wireframe' })).toBeVisible()
})
it('updates materialMode v-model when a mode is selected', async () => {
const { user, materialMode } = renderComponent({
materialModes: ['original', 'normal']
})
await user.click(screen.getByRole('button', { name: 'Material mode' }))
await user.click(screen.getByRole('button', { name: 'Normal' }))
expect(materialMode.value).toBe('normal')
})
})
describe('skeleton', () => {
it('hides the skeleton button when hasSkeleton is false', () => {
renderComponent({ hasSkeleton: false })
expect(
screen.queryByRole('button', { name: 'Show skeleton' })
).not.toBeInTheDocument()
})
it('renders the skeleton button when hasSkeleton is true', () => {
renderComponent({ hasSkeleton: true })
expect(
screen.getByRole('button', { name: 'Show skeleton' })
).toBeInTheDocument()
})
it('flips showSkeleton v-model when the skeleton button is clicked', async () => {
const { user, showSkeleton } = renderComponent({
hasSkeleton: true,
showSkeleton: false
})
await user.click(screen.getByRole('button', { name: 'Show skeleton' }))
expect(showSkeleton.value).toBe(true)
})
})
describe('popup mutual exclusion', () => {
it('closes the up-direction popup when the material-mode trigger is clicked', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Up direction' }))
expect(screen.getByRole('button', { name: 'ORIGINAL' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Material mode' }))
expect(
screen.queryByRole('button', { name: 'ORIGINAL' })
).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,126 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
vi.mock('primevue/slider', () => ({
default: {
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
`
}
}))
function renderComponent(
props: {
tooltipText?: string
icon?: string
min?: number
max?: number
step?: number
initial?: number
} = {}
) {
const value = ref<number>(props.initial ?? 50)
const utils = render(PopupSlider, {
props: {
tooltipText: props.tooltipText ?? 'FOV',
icon: props.icon,
min: props.min,
max: props.max,
step: props.step,
modelValue: value.value,
'onUpdate:modelValue': (v: number | undefined) => {
if (v !== undefined) value.value = v
}
},
global: {
directives: { tooltip: () => {} }
}
})
return { ...utils, value, user: userEvent.setup() }
}
describe('PopupSlider', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('keeps the slider hidden from the accessibility tree until the trigger is clicked', () => {
renderComponent({ tooltipText: 'FOV' })
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
})
it('reveals the slider when the trigger is clicked and hides it again on a second click', async () => {
const { user } = renderComponent({ tooltipText: 'FOV' })
await user.click(screen.getByRole('button', { name: 'FOV' }))
expect(screen.getByRole('slider')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'FOV' }))
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
})
it('hides the slider when the user clicks outside the popup', async () => {
const { user } = renderComponent({ tooltipText: 'FOV' })
await user.click(screen.getByRole('button', { name: 'FOV' }))
expect(screen.getByRole('slider')).toBeVisible()
await user.click(document.body)
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
})
it('forwards default min / max / step (10 / 150 / 1) when none are provided', async () => {
const { user } = renderComponent({ tooltipText: 'FOV' })
await user.click(screen.getByRole('button', { name: 'FOV' }))
const slider = screen.getByRole('slider') as HTMLInputElement
expect(slider.min).toBe('10')
expect(slider.max).toBe('150')
expect(slider.step).toBe('1')
})
it('uses caller-provided min / max / step over the defaults', async () => {
const { user } = renderComponent({
tooltipText: 'Light',
min: 0,
max: 5,
step: 0.25
})
await user.click(screen.getByRole('button', { name: 'Light' }))
const slider = screen.getByRole('slider') as HTMLInputElement
expect(slider.min).toBe('0')
expect(slider.max).toBe('5')
expect(slider.step).toBe('0.25')
})
it('updates the v-model when the slider value changes', async () => {
const { user, value } = renderComponent({
tooltipText: 'FOV',
initial: 50
})
await user.click(screen.getByRole('button', { name: 'FOV' }))
const slider = screen.getByRole('slider') as HTMLInputElement
slider.value = '120'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(value.value).toBe(120)
})
})

View File

@@ -0,0 +1,205 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
startRecording: 'Start recording',
stopRecording: 'Stop recording',
exportRecording: 'Export recording',
clearRecording: 'Clear recording'
}
}
}
})
type RenderOpts = {
hasRecording?: boolean
isRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderComponent(opts: RenderOpts = {}) {
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
const isRecording = ref<boolean>(opts.isRecording ?? false)
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
const utils = render(RecordingControls, {
props: {
hasRecording: hasRecording.value,
'onUpdate:hasRecording': (v: boolean | undefined) => {
if (v !== undefined) hasRecording.value = v
},
isRecording: isRecording.value,
'onUpdate:isRecording': (v: boolean | undefined) => {
if (v !== undefined) isRecording.value = v
},
recordingDuration: recordingDuration.value,
'onUpdate:recordingDuration': (v: number | undefined) => {
if (v !== undefined) recordingDuration.value = v
},
onStartRecording: opts.onStartRecording,
onStopRecording: opts.onStopRecording,
onExportRecording: opts.onExportRecording,
onClearRecording: opts.onClearRecording
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, user: userEvent.setup() }
}
describe('RecordingControls', () => {
it('shows the start-recording button initially', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Start recording' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Stop recording' })
).not.toBeInTheDocument()
})
it('shows the stop-recording button while recording is in progress', () => {
renderComponent({ isRecording: true })
expect(
screen.getByRole('button', { name: 'Stop recording' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Start recording' })
).not.toBeInTheDocument()
})
it('emits startRecording when the button is clicked from a stopped state', async () => {
const onStartRecording = vi.fn()
const onStopRecording = vi.fn()
const { user } = renderComponent({
isRecording: false,
onStartRecording,
onStopRecording
})
await user.click(screen.getByRole('button', { name: 'Start recording' }))
expect(onStartRecording).toHaveBeenCalledOnce()
expect(onStopRecording).not.toHaveBeenCalled()
})
it('emits stopRecording when the button is clicked from a recording state', async () => {
const onStartRecording = vi.fn()
const onStopRecording = vi.fn()
const { user } = renderComponent({
isRecording: true,
onStartRecording,
onStopRecording
})
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
expect(onStartRecording).not.toHaveBeenCalled()
})
it('hides the export and clear buttons when there is no recording', () => {
renderComponent({ hasRecording: false, isRecording: false })
expect(
screen.queryByRole('button', { name: 'Export recording' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear recording' })
).not.toBeInTheDocument()
})
it('shows the export and clear buttons once a recording exists', () => {
renderComponent({ hasRecording: true, isRecording: false })
expect(
screen.getByRole('button', { name: 'Export recording' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Clear recording' })
).toBeInTheDocument()
})
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
renderComponent({ hasRecording: true, isRecording: true })
expect(
screen.queryByRole('button', { name: 'Export recording' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear recording' })
).not.toBeInTheDocument()
})
it('emits exportRecording and clearRecording from their respective buttons', async () => {
const onExportRecording = vi.fn()
const onClearRecording = vi.fn()
const { user } = renderComponent({
hasRecording: true,
isRecording: false,
onExportRecording,
onClearRecording
})
await user.click(screen.getByRole('button', { name: 'Export recording' }))
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('renders the formatted duration as MM:SS once a recording exists', () => {
renderComponent({
hasRecording: true,
isRecording: false,
recordingDuration: 75
})
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
'01:15'
)
})
it('hides the duration display while a recording is in progress', () => {
renderComponent({
hasRecording: true,
isRecording: true,
recordingDuration: 30
})
expect(
screen.queryByTestId('load3d-recording-duration')
).not.toBeInTheDocument()
})
it('hides the duration display when recordingDuration is zero', () => {
renderComponent({
hasRecording: true,
isRecording: false,
recordingDuration: 0
})
expect(
screen.queryByTestId('load3d-recording-duration')
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,231 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden color/file inputs have no role/label, queried by selector */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
default: {
name: 'PopupSliderStub',
props: ['tooltipText', 'modelValue'],
template: '<div data-testid="fov-popup-slider">{{ tooltipText }}</div>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
showGrid: 'Show grid',
backgroundColor: 'Background color',
uploadBackgroundImage: 'Upload background image',
panoramaMode: 'Panorama mode',
removeBackgroundImage: 'Remove background image',
fov: 'FOV'
}
}
}
})
type RenderOpts = {
showGrid?: boolean
backgroundColor?: string
backgroundImage?: string
backgroundRenderMode?: 'tiled' | 'panorama'
fov?: number
hdriActive?: boolean
onUpdateBackgroundImage?: (file: File | null) => void
}
function renderComponent(opts: RenderOpts = {}) {
const showGrid = ref<boolean>(opts.showGrid ?? true)
const backgroundColor = ref<string>(opts.backgroundColor ?? '#000000')
const backgroundImage = ref<string>(opts.backgroundImage ?? '')
const backgroundRenderMode = ref<'tiled' | 'panorama'>(
opts.backgroundRenderMode ?? 'tiled'
)
const fov = ref<number>(opts.fov ?? 75)
const utils = render(SceneControls, {
props: {
showGrid: showGrid.value,
'onUpdate:showGrid': (v: boolean | undefined) => {
if (v !== undefined) showGrid.value = v
},
backgroundColor: backgroundColor.value,
'onUpdate:backgroundColor': (v: string | undefined) => {
if (v !== undefined) backgroundColor.value = v
},
backgroundImage: backgroundImage.value,
'onUpdate:backgroundImage': (v: string | undefined) => {
if (v !== undefined) backgroundImage.value = v
},
backgroundRenderMode: backgroundRenderMode.value,
'onUpdate:backgroundRenderMode': (
v: 'tiled' | 'panorama' | undefined
) => {
if (v) backgroundRenderMode.value = v
},
fov: fov.value,
'onUpdate:fov': (v: number | undefined) => {
if (v !== undefined) fov.value = v
},
hdriActive: opts.hdriActive ?? false,
onUpdateBackgroundImage: opts.onUpdateBackgroundImage
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return {
...utils,
showGrid,
backgroundColor,
backgroundRenderMode,
user: userEvent.setup()
}
}
describe('SceneControls', () => {
describe('grid', () => {
it('flips showGrid via v-model when the grid button is clicked', async () => {
const { user, showGrid } = renderComponent({ showGrid: false })
await user.click(screen.getByRole('button', { name: 'Show grid' }))
expect(showGrid.value).toBe(true)
})
})
describe('hdriActive=true', () => {
it('hides the background-color and upload buttons when HDRI is active', () => {
renderComponent({ hdriActive: true, backgroundImage: '' })
expect(
screen.queryByRole('button', { name: 'Background color' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Upload background image' })
).not.toBeInTheDocument()
})
})
describe('without a background image', () => {
it('renders the background-color and upload buttons', () => {
renderComponent({ backgroundImage: '' })
expect(
screen.getByRole('button', { name: 'Background color' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Upload background image' })
).toBeInTheDocument()
})
it('does not render the panorama / remove / FOV controls', () => {
renderComponent({ backgroundImage: '' })
expect(
screen.queryByRole('button', { name: 'Panorama mode' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Remove background image' })
).not.toBeInTheDocument()
expect(screen.queryByTestId('fov-popup-slider')).not.toBeInTheDocument()
})
it('updates backgroundColor v-model from the hidden color picker', async () => {
const { backgroundColor, container } = renderComponent({
backgroundImage: '',
backgroundColor: '#000000'
})
const colorInput = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
colorInput.value = '#ff0000'
colorInput.dispatchEvent(new Event('input', { bubbles: true }))
expect(backgroundColor.value).toBe('#ff0000')
})
it('emits updateBackgroundImage with the picked file', async () => {
const onUpdateBackgroundImage = vi.fn()
const { container } = renderComponent({
backgroundImage: '',
onUpdateBackgroundImage
})
const fileInput = container.querySelector(
'input[type="file"]'
) as HTMLInputElement
const file = new File(['data'], 'bg.png', { type: 'image/png' })
Object.defineProperty(fileInput, 'files', { value: [file] })
fileInput.dispatchEvent(new Event('change'))
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
})
})
describe('with a background image', () => {
it('renders the panorama and remove buttons', () => {
renderComponent({ backgroundImage: 'bg.png' })
expect(
screen.getByRole('button', { name: 'Panorama mode' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Remove background image' })
).toBeInTheDocument()
})
it('toggles backgroundRenderMode between tiled and panorama on the panorama button', async () => {
const { user, backgroundRenderMode } = renderComponent({
backgroundImage: 'bg.png',
backgroundRenderMode: 'tiled'
})
await user.click(screen.getByRole('button', { name: 'Panorama mode' }))
expect(backgroundRenderMode.value).toBe('panorama')
})
it('hides the FOV PopupSlider in tiled mode', () => {
renderComponent({
backgroundImage: 'bg.png',
backgroundRenderMode: 'tiled'
})
expect(screen.queryByTestId('fov-popup-slider')).not.toBeInTheDocument()
})
it('shows the FOV PopupSlider in panorama mode', () => {
renderComponent({
backgroundImage: 'bg.png',
backgroundRenderMode: 'panorama'
})
expect(screen.getByTestId('fov-popup-slider')).toBeInTheDocument()
})
it('emits updateBackgroundImage(null) when the remove button is clicked', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderComponent({
backgroundImage: 'bg.png',
onUpdateBackgroundImage
})
await user.click(
screen.getByRole('button', { name: 'Remove background image' })
)
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
})
})
})

View File

@@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const showDialog = vi.fn()
const handleViewerClose = vi.fn()
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({ handleViewerClose })
}))
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({
default: { name: 'Load3DViewerContentStub', template: '<div />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
openIn3DViewer: 'Open in 3D viewer',
viewer: { title: '3D viewer' }
}
}
}
})
const mockNode = createMockLGraphNode({ id: 'node-1' })
describe('ViewerControls', () => {
beforeEach(() => {
showDialog.mockClear()
handleViewerClose.mockClear()
})
it('renders the open-in-viewer button labeled by the localized aria-label', () => {
render(ViewerControls, {
props: { node: mockNode },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
expect(
screen.getByRole('button', { name: 'Open in 3D viewer' })
).toBeInTheDocument()
})
it('opens the dialog with the provided node and viewer component when clicked', async () => {
const user = userEvent.setup()
render(ViewerControls, {
props: { node: mockNode },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByRole('button', { name: 'Open in 3D viewer' }))
expect(showDialog).toHaveBeenCalledOnce()
const callArgs = showDialog.mock.calls[0][0]
expect(callArgs.key).toBe('global-load3d-viewer')
expect(callArgs.title).toBe('3D viewer')
expect(callArgs.component).toMatchObject({
name: 'Load3DViewerContentStub'
})
expect(callArgs.props).toEqual({ node: mockNode })
expect(callArgs.dialogComponentProps.maximizable).toBe(true)
})
it('routes the dialog onClose handler through useLoad3dService.handleViewerClose with the node', async () => {
const user = userEvent.setup()
render(ViewerControls, {
props: { node: mockNode },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByRole('button', { name: 'Open in 3D viewer' }))
const onClose = showDialog.mock.calls[0][0].dialogComponentProps.onClose
await onClose()
expect(handleViewerClose).toHaveBeenCalledWith(mockNode)
})
})

View File

@@ -0,0 +1,132 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('primevue/slider', () => ({
default: {
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
emits: ['update:modelValue'],
template: `
<input
type="range"
:value="modelValue"
:min="min"
:max="max"
:step="step"
:aria-label="ariaLabel"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
`
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
fov: 'FOV',
viewer: { cameraType: 'Camera type' },
cameraType: {
perspective: 'Perspective',
orthographic: 'Orthographic'
}
}
}
}
})
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
const fov = ref<number>(initial.fov ?? 75)
const utils = render(ViewerCameraControls, {
props: {
cameraType: cameraType.value,
'onUpdate:cameraType': (v: CameraType | undefined) => {
if (v) cameraType.value = v
},
fov: fov.value,
'onUpdate:fov': (v: number | undefined) => {
if (v !== undefined) fov.value = v
}
},
global: { plugins: [i18n] }
})
return { ...utils, cameraType, fov, user: userEvent.setup() }
}
describe('ViewerCameraControls', () => {
it('exposes both camera types in the dropdown', () => {
renderComponent()
const select = screen.getByRole('combobox') as HTMLSelectElement
const options = Array.from(select.options).map((o) => o.value)
expect(options).toEqual(['perspective', 'orthographic'])
})
it('shows the FOV slider when the camera is perspective', () => {
renderComponent({ type: 'perspective' })
expect(screen.getByLabelText('FOV')).toBeInTheDocument()
})
it('hides the FOV slider when the camera is orthographic', () => {
renderComponent({ type: 'orthographic' })
expect(screen.queryByLabelText('FOV')).not.toBeInTheDocument()
})
it('reveals the FOV slider when the camera type prop changes back to perspective', async () => {
const { rerender } = renderComponent({ type: 'orthographic' })
expect(screen.queryByLabelText('FOV')).not.toBeInTheDocument()
await rerender({ cameraType: 'perspective' })
expect(screen.getByLabelText('FOV')).toBeInTheDocument()
})
it('updates fov via v-model when the slider changes', () => {
const { fov } = renderComponent({ type: 'perspective', fov: 60 })
const slider = screen.getByLabelText('FOV') as HTMLInputElement
slider.value = '90'
slider.dispatchEvent(new Event('input', { bubbles: true }))
expect(fov.value).toBe(90)
})
it('updates cameraType via v-model when the dropdown changes', async () => {
const { user, cameraType } = renderComponent({ type: 'perspective' })
await user.selectOptions(screen.getByRole('combobox'), 'orthographic')
expect(cameraType.value).toBe('orthographic')
})
})

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