mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 03:37:48 +00:00
Compare commits
52 Commits
fix/subgra
...
batch-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d92680ce4 | ||
|
|
167a1e6a0c | ||
|
|
e4e1546458 | ||
|
|
c1954028d1 | ||
|
|
5cad2c952b | ||
|
|
e356addeb6 | ||
|
|
e831daae59 | ||
|
|
96575fcec9 | ||
|
|
e7e1ae25a6 | ||
|
|
4ed00cec08 | ||
|
|
f566abdd6e | ||
|
|
3c5695fd42 | ||
|
|
4fff0c4b49 | ||
|
|
69dca2d600 | ||
|
|
004530b23a | ||
|
|
73d4e24ffa | ||
|
|
09790bd7f3 | ||
|
|
dafb944c3b | ||
|
|
d429d481e8 | ||
|
|
a9aae6af4a | ||
|
|
46ba65e25c | ||
|
|
11432f7d0e | ||
|
|
9384beaec6 | ||
|
|
4a05d89fdb | ||
|
|
ef98ba0e8f | ||
|
|
019c1787a5 | ||
|
|
87fab87d84 | ||
|
|
f2a99adaa3 | ||
|
|
a934056246 | ||
|
|
b8dfbfc0bb | ||
|
|
036c79259b | ||
|
|
17c18b0707 | ||
|
|
ca8407218b | ||
|
|
26ac1eece1 | ||
|
|
810381ab63 | ||
|
|
cc1a737291 | ||
|
|
c74e08e244 | ||
|
|
8f9f452c86 | ||
|
|
9e16390c33 | ||
|
|
c88275b2a4 | ||
|
|
23e48b2140 | ||
|
|
af43619ae1 | ||
|
|
d2e88011aa | ||
|
|
180a0001e8 | ||
|
|
8f011225bf | ||
|
|
3c50487c18 | ||
|
|
818e549e8e | ||
|
|
fd1a8e9432 | ||
|
|
8f61ecd82e | ||
|
|
8fe0385a57 | ||
|
|
d078af3a79 | ||
|
|
57d708767a |
156
.claude/skills/reviewing-unit-tests/SKILL.md
Normal file
156
.claude/skills/reviewing-unit-tests/SKILL.md
Normal 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
23
.github/actions/ashby-pull/action.yaml
vendored
Normal 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
|
||||
87
.github/actions/changes-filter/action.yaml
vendored
Normal file
87
.github/actions/changes-filter/action.yaml
vendored
Normal 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'
|
||||
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -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'
|
||||
|
||||
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -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'
|
||||
|
||||
16
.github/workflows/ci-perf-report.yaml
vendored
16
.github/workflows/ci-perf-report.yaml
vendored
@@ -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,8 +14,20 @@ 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:
|
||||
|
||||
15
.github/workflows/ci-size-data.yaml
vendored
15
.github/workflows/ci-size-data.yaml
vendored
@@ -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
|
||||
|
||||
35
.github/workflows/ci-tests-e2e.yaml
vendored
35
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -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
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
47
.github/workflows/ci-tests-storybook.yaml
vendored
47
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/ci-tests-unit.yaml
vendored
15
.github/workflows/ci-tests-unit.yaml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
25
.github/workflows/ci-website-build.yaml
vendored
25
.github/workflows/ci-website-build.yaml
vendored
@@ -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
|
||||
|
||||
32
.github/workflows/ci-website-e2e.yaml
vendored
32
.github/workflows/ci-website-e2e.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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
59
.github/workflows/release-website.yaml
vendored
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
@@ -26,6 +26,7 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
126
apps/website/src/components/contact/HubspotFormEmbed.vue
Normal file
126
apps/website/src/components/contact/HubspotFormEmbed.vue
Normal 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>
|
||||
@@ -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': {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
36
apps/website/src/scripts/posthog.ts
Normal file
36
apps/website/src/scripts/posthog.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
36
apps/website/src/utils/github.test.ts
Normal file
36
apps/website/src/utils/github.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
27
browser_tests/assets/3d/load3d_missing_model.json
Normal file
27
browser_tests/assets/3d/load3d_missing_model.json
Normal 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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,287 +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": {
|
||||
"proxyWidgets": [["10", "text", null, { "value": "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": {
|
||||
"proxyWidgets": [["10", "text", null, { "value": "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": {
|
||||
"proxyWidgets": [["10", "text", null, { "value": "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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -119,10 +126,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' })
|
||||
|
||||
@@ -133,6 +139,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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
54
browser_tests/fixtures/components/WidgetBoundingBox.ts
Normal file
54
browser_tests/fixtures/components/WidgetBoundingBox.ts
Normal 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')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -210,7 +210,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',
|
||||
@@ -261,6 +263,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}`
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
121
browser_tests/tests/queue/queueSettings.spec.ts
Normal file
121
browser_tests/tests/queue/queueSettings.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal file
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
50
browser_tests/tests/vueNodes/widgets/legacy.spec.ts
Normal file
50
browser_tests/tests/vueNodes/widgets/legacy.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
185
browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts
Normal file
185
browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
205
browser_tests/tests/workflowSettings.spec.ts
Normal file
205
browser_tests/tests/workflowSettings.spec.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.13",
|
||||
"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:",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
64
packages/registry-types/src/comfyRegistryTypes.ts
generated
64
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -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: {
|
||||
|
||||
@@ -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
41
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
85
src/components/dialog/content/PromptDialogContent.test.ts
Normal file
85
src/components/dialog/content/PromptDialogContent.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
110
src/components/dialog/content/signin/SignUpForm.test.ts
Normal file
110
src/components/dialog/content/signin/SignUpForm.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -140,11 +140,11 @@ const iconClass = computed(() => {
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'animate-spin text-slate-100'
|
||||
return 'animate-spin text-text-secondary'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'text-danger-200'
|
||||
}
|
||||
return 'text-slate-100'
|
||||
return 'text-text-secondary'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
data-testid="queue-notification-banner"
|
||||
>
|
||||
<QueueNotificationBanner :notification="currentNotification" />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -77,15 +76,8 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const storeInstanceId =
|
||||
node.isSubgraphNode() && isPromotedWidgetView(widget) ? node.id : undefined
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(
|
||||
graphId,
|
||||
bareNodeId,
|
||||
sourceWidget.name,
|
||||
storeInstanceId
|
||||
)
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -15,10 +17,14 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1280, height: 800 }
|
||||
const MOBILE_VIEWPORT = { width: 360, height: 800 }
|
||||
|
||||
describe('NodeSearchContent', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
@@ -547,7 +553,7 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
describe('filter integration', () => {
|
||||
it('should display active filters in the input area', () => {
|
||||
it('renders one chip per active filter with the filter value', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -556,16 +562,20 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
|
||||
renderComponent({
|
||||
filters: [
|
||||
{
|
||||
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
|
||||
value: 'IMAGE'
|
||||
}
|
||||
{ filterDef: inputFilter, value: 'IMAGE' },
|
||||
{ filterDef: inputFilter, value: 'LATENT' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
|
||||
const chipTexts = screen
|
||||
.getAllByTestId('filter-chip')
|
||||
.map((c) => c.textContent ?? '')
|
||||
expect(chipTexts).toHaveLength(2)
|
||||
expect(chipTexts.some((t) => t.includes('IMAGE'))).toBe(true)
|
||||
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -659,6 +669,95 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar toggle', () => {
|
||||
it('should hide and show the category sidebar when the toggle is clicked', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const sidebar = await screen.findByTestId('category-sampling')
|
||||
expect(sidebar).toBeVisible()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByTestId('category-sampling')).not.toBeVisible()
|
||||
})
|
||||
|
||||
await user.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('category-sampling')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the sidebar when the search input gains focus on mobile', async () => {
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve user state across mobile/desktop resizes', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', value))
|
||||
|
||||
await expectExpanded('true')
|
||||
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
|
||||
await user.click(toggle)
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
await expectExpanded('true')
|
||||
|
||||
await user.click(toggle)
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rootFilter + category + search combination', () => {
|
||||
it('should intersect rootFilter, selected category, and search query', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
@navigate-down="navigateResults(1)"
|
||||
@navigate-up="navigateResults(-1)"
|
||||
@select-current="selectCurrentResult"
|
||||
@focusin="onSearchFocus"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
v-model:is-sidebar-open="isSidebarOpen"
|
||||
class="flex-1"
|
||||
:filters="filters"
|
||||
:active-category="rootFilter"
|
||||
@@ -34,11 +36,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar -->
|
||||
<div class="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<NodeSearchCategorySidebar
|
||||
v-show="isSidebarOpen"
|
||||
id="node-search-category-sidebar"
|
||||
v-model:selected-category="sidebarCategory"
|
||||
class="w-52 shrink-0"
|
||||
:aria-label="isMobile ? t('g.categories') : undefined"
|
||||
class="w-52 shrink-0 max-md:absolute max-md:inset-y-0 max-md:left-0 max-md:z-20 max-md:bg-base-background max-md:shadow-interface"
|
||||
:hide-chevrons="!anyTreeCategoryHasChildren"
|
||||
:hide-presets="rootFilter !== null"
|
||||
:node-defs="rootFilteredNodeDefs"
|
||||
@@ -47,6 +51,14 @@
|
||||
@auto-expand="selectedCategory = $event"
|
||||
/>
|
||||
|
||||
<!-- Mobile overlay backdrop to close sidebar on outside click -->
|
||||
<div
|
||||
v-if="isMobile && isSidebarOpen"
|
||||
data-testid="sidebar-backdrop"
|
||||
class="absolute inset-0 z-10 md:hidden"
|
||||
@click="isSidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Results list -->
|
||||
<div
|
||||
id="results-list"
|
||||
@@ -78,8 +90,8 @@
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="rootFilter !== 'essentials'"
|
||||
:hide-bookmark-icon="selectedCategory === 'favorites'"
|
||||
:show-source-badge="rootFilter !== RootCategory.Essentials"
|
||||
:hide-bookmark-icon="selectedCategory === RootCategory.Favorites"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -96,6 +108,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { FocusScope } from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -106,6 +119,8 @@ import NodeSearchCategorySidebar, {
|
||||
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -121,9 +136,9 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
{
|
||||
essentials: isEssentialNode,
|
||||
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
custom: isCustomNode
|
||||
[RootCategory.Essentials]: isEssentialNode,
|
||||
[RootCategory.Comfy]: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
@@ -167,22 +182,33 @@ const searchQuery = ref('')
|
||||
const selectedCategory = ref(DEFAULT_CATEGORY)
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
const isSidebarOpen = ref(!isMobile.value)
|
||||
watch(isMobile, (mobile) => {
|
||||
// On transitioning to mobile state, close the sidebar
|
||||
if (mobile) isSidebarOpen.value = false
|
||||
})
|
||||
|
||||
function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<string | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
case RootCategory.Favorites:
|
||||
return t('g.bookmarked')
|
||||
case BLUEPRINT_CATEGORY:
|
||||
case RootCategory.Blueprint:
|
||||
return t('g.blueprints')
|
||||
case 'partner-nodes':
|
||||
case RootCategory.PartnerNodes:
|
||||
return t('g.partner')
|
||||
case 'essentials':
|
||||
case RootCategory.Essentials:
|
||||
return t('g.essentials')
|
||||
case 'comfy':
|
||||
case RootCategory.Comfy:
|
||||
return t('g.comfy')
|
||||
case 'custom':
|
||||
case RootCategory.Custom:
|
||||
return t('g.extensions')
|
||||
default:
|
||||
return undefined
|
||||
@@ -195,11 +221,11 @@ const rootFilteredNodeDefs = computed(() => {
|
||||
const sourceFilter = sourceCategoryFilters[rootFilter.value]
|
||||
if (sourceFilter) return allNodes.filter(sourceFilter)
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
case RootCategory.Favorites:
|
||||
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
|
||||
case 'partner-nodes':
|
||||
case RootCategory.Blueprint:
|
||||
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
|
||||
case RootCategory.PartnerNodes:
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
default:
|
||||
return allNodes
|
||||
@@ -226,7 +252,7 @@ function onClearFilterGroup(filterId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectCategory(category: string) {
|
||||
function onSelectCategory(category: RootCategoryId) {
|
||||
if (rootFilter.value === category) {
|
||||
rootFilter.value = null
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -9,23 +9,16 @@ import {
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe(NodeSearchFilterBar, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setupTestPinia()
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -38,8 +31,13 @@ describe(NodeSearchFilterBar, () => {
|
||||
async function createRender(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onSelectCategory = vi.fn()
|
||||
const onUpdateIsSidebarOpen = vi.fn()
|
||||
render(NodeSearchFilterBar, {
|
||||
props: { onSelectCategory, ...props },
|
||||
props: {
|
||||
onSelectCategory,
|
||||
'onUpdate:isSidebarOpen': onUpdateIsSidebarOpen,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
@@ -51,51 +49,38 @@ describe(NodeSearchFilterBar, () => {
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return { user, onSelectCategory }
|
||||
return { user, onSelectCategory, onUpdateIsSidebarOpen }
|
||||
}
|
||||
|
||||
it('should render Extensions button and Input/Output popover triggers', async () => {
|
||||
await createRender({ hasCustomNodes: true })
|
||||
const buttonTexts = () =>
|
||||
screen.getAllByRole('button').map((b) => b.textContent?.trim())
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const texts = buttons.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Extensions')
|
||||
it.each([
|
||||
{ prop: 'hasFavorites', label: 'Bookmarked' },
|
||||
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
|
||||
{ prop: 'hasEssentialNodes', label: 'Essentials' },
|
||||
{ prop: 'hasPartnerNodes', label: 'Partner' },
|
||||
{ prop: 'hasCustomNodes', label: 'Extensions' }
|
||||
] as const)(
|
||||
'shows the $label button only when $prop is true',
|
||||
async ({ prop, label }) => {
|
||||
await createRender()
|
||||
expect(buttonTexts()).not.toContain(label)
|
||||
|
||||
cleanup()
|
||||
await createRender({ [prop]: true })
|
||||
expect(buttonTexts()).toContain(label)
|
||||
}
|
||||
)
|
||||
|
||||
it('always renders the Comfy button and Input/Output type filter triggers', async () => {
|
||||
await createRender()
|
||||
const texts = buttonTexts()
|
||||
expect(texts).toContain('Comfy')
|
||||
expect(texts).toContain('Input')
|
||||
expect(texts).toContain('Output')
|
||||
})
|
||||
|
||||
it('should always render Comfy button', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Comfy')
|
||||
})
|
||||
|
||||
it('should render conditional category buttons when matching nodes exist', async () => {
|
||||
await createRender({
|
||||
hasFavorites: true,
|
||||
hasEssentialNodes: true,
|
||||
hasBlueprintNodes: true,
|
||||
hasPartnerNodes: true
|
||||
})
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Bookmarked')
|
||||
expect(texts).toContain('Blueprints')
|
||||
expect(texts).toContain('Partner')
|
||||
expect(texts).toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should not render Extensions button when no custom nodes exist', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should emit selectCategory when category button is clicked', async () => {
|
||||
const { user, onSelectCategory } = await createRender({
|
||||
hasCustomNodes: true
|
||||
@@ -114,4 +99,24 @@ describe(NodeSearchFilterBar, () => {
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose aria-expanded=false and emit update:isSidebarOpen=true when toggled from collapsed', async () => {
|
||||
const { user, onUpdateIsSidebarOpen } = await createRender({
|
||||
isSidebarOpen: false
|
||||
})
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(onUpdateIsSidebarOpen).toHaveBeenCalledExactlyOnceWith(true)
|
||||
})
|
||||
|
||||
it('should expose aria-expanded=true when isSidebarOpen prop is true', async () => {
|
||||
await createRender({ isSidebarOpen: true })
|
||||
expect(screen.getByTestId('toggle-category-sidebar')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,67 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2.5 px-3">
|
||||
<!-- Category filter buttons -->
|
||||
<div class="flex min-w-0 items-center gap-2.5 pl-3">
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
data-testid="toggle-category-sidebar"
|
||||
aria-controls="node-search-category-sidebar"
|
||||
:aria-expanded="isSidebarOpen"
|
||||
:aria-label="isSidebarOpen ? t('g.hideLeftPanel') : t('g.showLeftPanel')"
|
||||
:class="chipClass(isSidebarOpen)"
|
||||
@click="isSidebarOpen = !isSidebarOpen"
|
||||
>
|
||||
{{ btn.label }}
|
||||
<i class="icon-[lucide--panel-left] size-4" />
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
<div
|
||||
data-testid="filter-chips-scroll"
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto pr-3"
|
||||
>
|
||||
<!-- Category filter buttons -->
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
>
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
>
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,6 +82,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
@@ -86,11 +106,13 @@ const {
|
||||
hasCustomNodes?: boolean
|
||||
}>()
|
||||
|
||||
const isSidebarOpen = defineModel<boolean>('isSidebarOpen', { default: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
|
||||
clearFilterGroup: [filterId: string]
|
||||
focusSearch: []
|
||||
selectCategory: [category: string]
|
||||
selectCategory: [category: RootCategoryId]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -99,20 +121,20 @@ const nodeDefStore = useNodeDefStore()
|
||||
const MAX_VISIBLE_DOTS = 4
|
||||
|
||||
const categoryButtons = computed(() => {
|
||||
const buttons: { id: string; label: string }[] = []
|
||||
const buttons: { id: RootCategoryId; label: string }[] = []
|
||||
if (hasFavorites) {
|
||||
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
|
||||
}
|
||||
if (hasBlueprintNodes) {
|
||||
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
|
||||
}
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
|
||||
}
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
|
||||
}
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
|
||||
}
|
||||
if (hasCustomNodes) {
|
||||
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
|
||||
}
|
||||
@@ -146,7 +168,7 @@ const typeFilters = computed(() => [
|
||||
|
||||
function chipClass(isActive: boolean, hasSelections = false) {
|
||||
return cn(
|
||||
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
'flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
isActive
|
||||
? 'border-base-foreground bg-base-foreground text-base-background'
|
||||
: hasSelections
|
||||
|
||||
@@ -57,6 +57,19 @@ describe('NodeSearchListItem', () => {
|
||||
})
|
||||
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides id name for subgraph blueprints even when ShowIdName is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'SubgraphBlueprint.e21be61fc452df75e1324e3cc97c41fb0c01a08a5dad4dcd3a2ac118d8907025',
|
||||
display_name: 'My Blueprint',
|
||||
python_module: 'blueprint'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByTestId('node-id-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDescription mode', () => {
|
||||
|
||||
@@ -155,8 +155,10 @@ const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
)
|
||||
const showIdName = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
|
||||
const showIdName = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
|
||||
)
|
||||
const showNodeFrequency = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { DetachedWindowAPI } from 'happy-dom'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -35,3 +36,12 @@ export const testI18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
export function setViewport(viewport: { width: number; height: number }) {
|
||||
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
|
||||
.happyDOM
|
||||
if (!happyDOM) {
|
||||
throw new Error('window.happyDOM is unavailable to set viewport')
|
||||
}
|
||||
happyDOM.setViewport(viewport)
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ const dotClasses = computed(() => {
|
||||
return 'bg-gold-600'
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-slate-100'
|
||||
return 'bg-text-secondary'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
select: () => inputRef.value?.select()
|
||||
select: () => inputRef.value?.select(),
|
||||
blur: () => inputRef.value?.blur(),
|
||||
setSelectionRange: (start: number, end: number) =>
|
||||
inputRef.value?.setSelectionRange(start, end),
|
||||
selectAll: () =>
|
||||
inputRef.value?.setSelectionRange(0, inputRef.value.value.length)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
89
src/components/ui/search-input/SearchAutocomplete.test.ts
Normal file
89
src/components/ui/search-input/SearchAutocomplete.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchAutocomplete from './SearchAutocomplete.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
|
||||
})
|
||||
|
||||
describe('SearchAutocomplete', () => {
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
return render(SearchAutocomplete, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: { template: '<div><slot /></div>' },
|
||||
ComboboxAnchor: { template: '<div><slot /></div>' },
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue']
|
||||
},
|
||||
ComboboxPortal: { template: '<div><slot /></div>' },
|
||||
ComboboxContent: { template: '<div><slot /></div>' },
|
||||
ComboboxItem: {
|
||||
template:
|
||||
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
|
||||
emits: ['select']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: { modelValue: '', ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('suggestions dropdown', () => {
|
||||
it('does not render items when suggestions list is empty', () => {
|
||||
renderComponent({ suggestions: [] })
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a button for each suggestion', () => {
|
||||
renderComponent({ suggestions: ['foo', 'bar'] })
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits select with the suggestion when an item is clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onSelect).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
|
||||
it('updates modelValue to the suggestion label on selection', async () => {
|
||||
const onUpdateModelValue = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({
|
||||
suggestions: ['foo', 'bar'],
|
||||
'onUpdate:modelValue': onUpdateModelValue
|
||||
})
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with optionLabel', () => {
|
||||
it('displays the optionLabel property as the suggestion text', () => {
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query' })
|
||||
expect(screen.getByText('my-extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits the full item object on selection when optionLabel is set', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query', onSelect })
|
||||
await user.click(screen.getByText('my-extension'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -65,34 +65,36 @@
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +107,7 @@ import {
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restAttrs } = defineProps<{
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
|
||||
const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => textareaEl.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-bind="restAttrs"
|
||||
ref="textareaEl"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile && !showLeftPanel"
|
||||
size="lg"
|
||||
|
||||
@@ -229,7 +229,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.value).toBe('hello')
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
|
||||
// Disconnect
|
||||
@@ -476,7 +475,6 @@ describe('Nested promoted widget mapping', () => {
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
expect(mappedWidget?.storeInstanceId).toBe(String(subgraphNodeB.id))
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
@@ -589,9 +587,6 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeInstanceId))
|
||||
).toEqual(new Set([String(outerSubgraphNode.id)]))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -50,17 +50,14 @@ export interface WidgetSlotMetadata {
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* widgetValueStore is preferred for value and metadata. `value` provides the
|
||||
* LiteGraph fallback when no scoped store entry exists yet.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
nodeId?: NodeId
|
||||
storeNodeId?: NodeId
|
||||
storeInstanceId?: NodeId
|
||||
name: string
|
||||
storeName?: string
|
||||
type: string
|
||||
value?: WidgetValue
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
@@ -339,13 +336,9 @@ function safeWidgetMapper(
|
||||
return {
|
||||
nodeId,
|
||||
storeNodeId: nodeId,
|
||||
storeInstanceId: isPromotedWidgetView(widget)
|
||||
? String(node.id)
|
||||
: undefined,
|
||||
name,
|
||||
storeName,
|
||||
type: effectiveWidget.type,
|
||||
value: normalizeWidgetValue(widget.value),
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
|
||||
142
src/composables/maskeditor/useBrushAdjustment.test.ts
Normal file
142
src/composables/maskeditor/useBrushAdjustment.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
|
||||
useCoordinateTransform: () => ({
|
||||
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { registerExtension: vi.fn() }
|
||||
}))
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useBrushAdjustment } from './useBrushAdjustment'
|
||||
|
||||
function makePointerEvent(offsetX: number, offsetY: number): PointerEvent {
|
||||
return {
|
||||
offsetX,
|
||||
offsetY,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('startBrushAdjustment', () => {
|
||||
it('sets brushPreviewGradientVisible to true', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushPreviewGradientVisible = false
|
||||
const { startBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
expect(store.brushPreviewGradientVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBrushAdjustment', () => {
|
||||
it('does nothing when startBrushAdjustment has not been called', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const hardnessBefore = store.brushSettings.hardness
|
||||
const { handleBrushAdjustment } = useBrushAdjustment()
|
||||
await handleBrushAdjustment(makePointerEvent(200, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBe(hardnessBefore)
|
||||
})
|
||||
|
||||
it('does not change size when deltaX is within the dead zone', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(103, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
})
|
||||
|
||||
it('increases size when dragging right past the dead zone', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
|
||||
})
|
||||
|
||||
it('does not compound size when pointer stays at the same position', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
const sizeAfterFirstMove = store.brushSettings.size
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeAfterFirstMove)
|
||||
})
|
||||
|
||||
it('continues increasing size beyond 100px drag (no delta saturation)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(100, 0))
|
||||
const sizeAt100px = store.brushSettings.size
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(300, 0))
|
||||
const sizeAt300px = store.brushSettings.size
|
||||
|
||||
expect(sizeAt300px).toBeGreaterThan(sizeAt100px)
|
||||
})
|
||||
|
||||
it('clamps size to minimum 1 when dragging far left', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.size = 2
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(500, 100))
|
||||
await handleBrushAdjustment(makePointerEvent(0, 100))
|
||||
expect(store.brushSettings.size).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps hardness to maximum 1 when dragging far up', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.9
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
brushAdjustmentSpeed: 10
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(100, 500))
|
||||
await handleBrushAdjustment(makePointerEvent(100, 0))
|
||||
expect(store.brushSettings.hardness).toBe(1)
|
||||
})
|
||||
|
||||
it('suppresses hardness change when X delta dominates (useDominantAxis=true)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.5
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
useDominantAxis: true
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(100, 10))
|
||||
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBe(0.5)
|
||||
})
|
||||
|
||||
it('suppresses size change when Y delta dominates (useDominantAxis=true)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.5
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
useDominantAxis: true
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const hardnessBefore = store.brushSettings.hardness
|
||||
await handleBrushAdjustment(makePointerEvent(10, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBeLessThan(hardnessBefore)
|
||||
})
|
||||
})
|
||||
83
src/composables/maskeditor/useBrushAdjustment.ts
Normal file
83
src/composables/maskeditor/useBrushAdjustment.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
|
||||
export function useBrushAdjustment(initialSettings?: {
|
||||
useDominantAxis?: boolean
|
||||
brushAdjustmentSpeed?: number
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const initialBrushSize = ref(0)
|
||||
const initialBrushHardness = ref(0)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
initialBrushSize.value = store.brushSettings.size
|
||||
initialBrushHardness.value = store.brushSettings.hardness
|
||||
}
|
||||
|
||||
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
500,
|
||||
initialBrushSize.value + (finalDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
initialBrushHardness.value -
|
||||
(finalDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
return { startBrushAdjustment, handleBrushAdjustment }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { tgpu } from 'typegpu'
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { StrokeProcessor } from './StrokeProcessor'
|
||||
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
|
||||
import { useBrushAdjustment } from './useBrushAdjustment'
|
||||
import {
|
||||
resetDirtyRect,
|
||||
updateDirtyRect,
|
||||
@@ -30,6 +31,8 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const persistence = useBrushPersistence()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } =
|
||||
useBrushAdjustment(initialSettings)
|
||||
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
@@ -63,10 +66,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
// Stroke processor instance
|
||||
let strokeProcessor: StrokeProcessor | null = null
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
persistence.loadAndApply()
|
||||
|
||||
// Handle external clear events
|
||||
@@ -753,78 +752,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the brush adjustment interaction.
|
||||
* @param event - The pointer event.
|
||||
*/
|
||||
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the brush adjustment movement.
|
||||
* @param event - The pointer event.
|
||||
*/
|
||||
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
|
||||
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
500,
|
||||
store.brushSettings.size +
|
||||
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
store.brushSettings.hardness -
|
||||
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back the GPU textures to CPU ImageDatas.
|
||||
* @returns Object containing mask and rgb ImageDatas.
|
||||
|
||||
70
src/composables/node/useNodeAnimatedImage.test.ts
Normal file
70
src/composables/node/useNodeAnimatedImage.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock } = vi.hoisted(() => ({
|
||||
canvasInteractionsMock: {
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn(),
|
||||
forwardEventToCanvas: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: () => canvasInteractionsMock
|
||||
}))
|
||||
// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.)
|
||||
// that we cannot pull in here, so we stub only the constant we need.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview'
|
||||
}))
|
||||
|
||||
describe('useNodeAnimatedImage', () => {
|
||||
function setup() {
|
||||
vi.clearAllMocks()
|
||||
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
|
||||
const { showAnimatedPreview, removeAnimatedPreview } =
|
||||
useNodeAnimatedImage()
|
||||
showAnimatedPreview(node)
|
||||
const element = node.widgets[0].element
|
||||
document.body.append(element)
|
||||
onTestFinished(() => element.remove())
|
||||
return { node, element, showAnimatedPreview, removeAnimatedPreview }
|
||||
}
|
||||
|
||||
it('forwards non-right-click pointer events and wheel to the canvas while alive', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new WheelEvent('wheel'))
|
||||
element.dispatchEvent(new PointerEvent('pointermove'))
|
||||
element.dispatchEvent(new PointerEvent('pointerup'))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detaches every listener when the preview is removed', () => {
|
||||
const { node, element, removeAnimatedPreview } = setup()
|
||||
removeAnimatedPreview(node)
|
||||
|
||||
element.dispatchEvent(new WheelEvent('wheel'))
|
||||
element.dispatchEvent(new PointerEvent('pointermove'))
|
||||
element.dispatchEvent(new PointerEvent('pointerup'))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user