mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
2 Commits
test/cov-S
...
fix/error-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c0e217c0 | ||
|
|
e02776c793 |
@@ -1,156 +0,0 @@
|
||||
---
|
||||
name: reviewing-unit-tests
|
||||
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
|
||||
---
|
||||
|
||||
# Reviewing Unit Tests for ComfyUI_frontend
|
||||
|
||||
## Overview
|
||||
|
||||
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
|
||||
|
||||
## Review Workflow
|
||||
|
||||
1. Identify the test type: component, store, composable, util, or bugfix regression.
|
||||
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
|
||||
3. Open the authoritative doc section before judging structure.
|
||||
4. Scan the red flags below.
|
||||
5. State the verdict first. Name the failure mode. Cite the doc or rule.
|
||||
|
||||
## Source of Truth / Precedence
|
||||
|
||||
When docs and examples conflict, use this order:
|
||||
|
||||
1. Explicit repo rules, lint rules, and note blocks.
|
||||
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
|
||||
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
|
||||
4. Example snippets
|
||||
5. Prior diffs
|
||||
|
||||
Apply these repo-specific clarifications:
|
||||
|
||||
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
|
||||
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
|
||||
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
|
||||
- Motion != fix.
|
||||
|
||||
## 30-Second Red Flags
|
||||
|
||||
| If you see... | Failure mode | Default action |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
|
||||
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
|
||||
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
|
||||
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
|
||||
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
|
||||
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
|
||||
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
|
||||
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
|
||||
|
||||
## Rationalization Table
|
||||
|
||||
| Excuse | Reality |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
|
||||
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
|
||||
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
|
||||
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
|
||||
| "It is only a nit" | Explicit repo-rule violations are never nits. |
|
||||
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
|
||||
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
|
||||
|
||||
## Mocking Rules
|
||||
|
||||
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
|
||||
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
|
||||
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
|
||||
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
|
||||
|
||||
### Alias-by-Renaming
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const mockAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
// After: same indirection, new name
|
||||
function getToastAddMock() {
|
||||
return useToast().add
|
||||
}
|
||||
```
|
||||
|
||||
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
|
||||
|
||||
### `vi.mocked()` Scope
|
||||
|
||||
| Use case | `vi.mocked()` required? |
|
||||
| --------------------------------------------------------------- | ----------------------- |
|
||||
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
|
||||
| `.mock.calls`, `.mock.results` | Yes |
|
||||
| `expect(fn).toHaveBeenCalled()` | No |
|
||||
| `expect(fn).toHaveBeenCalledWith(...)` | No |
|
||||
|
||||
- Flag casts whenever `vi.mocked()` would narrow correctly.
|
||||
- Do not add `vi.mocked()` around assertion-only references just for style.
|
||||
|
||||
### Reset Hygiene
|
||||
|
||||
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
|
||||
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
|
||||
|
||||
### Third-Party Seams
|
||||
|
||||
- Distinguish trivial hooks from behavior-rich APIs.
|
||||
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
|
||||
- That exception does not justify mocking behavior-rich third-party modules.
|
||||
|
||||
### `vue-i18n`
|
||||
|
||||
- Never mock `vue-i18n` in component tests.
|
||||
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
|
||||
|
||||
## Test-Body Rules
|
||||
|
||||
| Smell | Review bar |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Change-detector test | Reject. Default values alone prove nothing. |
|
||||
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
|
||||
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
|
||||
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
|
||||
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
|
||||
|
||||
## Bugfix Regression Proof
|
||||
|
||||
For `fix:` PRs or bugfix diffs:
|
||||
|
||||
1. Identify the production change that fixes the bug.
|
||||
2. Verify the new test fails on pre-fix code, or ask the author to show it.
|
||||
3. If the test passes on broken code, request changes.
|
||||
|
||||
A regression test that never proves red does not pin the bug.
|
||||
|
||||
## Review Output Rules
|
||||
|
||||
- State verdict before procedural questions.
|
||||
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
|
||||
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
|
||||
- Link the authoritative doc section in the review comment.
|
||||
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| When you see... | Read this |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
|
||||
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
|
||||
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
|
||||
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
87
.github/actions/changes-filter/action.yaml
vendored
87
.github/actions/changes-filter/action.yaml
vendored
@@ -1,87 +0,0 @@
|
||||
# Outputs default to 'true' for non-pull_request events (push, merge_group):
|
||||
# granular path filtering is a PR-only optimization. This avoids the silent
|
||||
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
|
||||
# would never run on push.
|
||||
#
|
||||
# Shared dependency files (root package.json, pnpm-lock.yaml,
|
||||
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
|
||||
# output so a lockfile bump correctly invalidates each granular gate. They
|
||||
# are NOT folded into docs-changes.
|
||||
#
|
||||
# Two paths-filter steps are needed because predicate-quantifier=every is
|
||||
# required for the negated globs in `should-run` but breaks multi-pattern
|
||||
# OR filters like `docs:` and `deps:`.
|
||||
#
|
||||
# Requires the caller to have checked out the repository.
|
||||
|
||||
name: 'Detect Path Changes'
|
||||
description: >
|
||||
Computes typed *-changes outputs and a back-compat should-run for
|
||||
path-gated CI jobs.
|
||||
|
||||
outputs:
|
||||
should-run:
|
||||
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
|
||||
app-website-changes:
|
||||
description: 'Shared deps or `apps/website/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
|
||||
app-desktop-changes:
|
||||
description: 'Shared deps or `apps/desktop-ui/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
|
||||
app-frontend-changes:
|
||||
description: 'Shared deps or `src/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
|
||||
packages-changes:
|
||||
description: 'Shared deps or `packages/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
|
||||
storybook-changes:
|
||||
description: 'Shared deps or `.storybook/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
|
||||
docs-changes:
|
||||
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
|
||||
dependency-changes:
|
||||
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Filter typed changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
app_website:
|
||||
- 'apps/website/**'
|
||||
app_desktop:
|
||||
- 'apps/desktop-ui/**'
|
||||
app_frontend:
|
||||
- 'src/**'
|
||||
packages:
|
||||
- 'packages/**'
|
||||
storybook:
|
||||
- '.storybook/**'
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
deps:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
- name: Filter relevant changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: relevant
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
predicate-quantifier: 'every'
|
||||
filters: |
|
||||
relevant:
|
||||
- '**'
|
||||
- '!apps/**'
|
||||
- '!docs/**'
|
||||
- '!.storybook/**'
|
||||
- '!**/*.md'
|
||||
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -12,30 +12,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
scan:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -14,29 +14,16 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
validate-fonts:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -81,17 +68,15 @@ jobs:
|
||||
echo '✅ No proprietary fonts found in dist'
|
||||
|
||||
validate-licenses:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
16
.github/workflows/ci-perf-report.yaml
vendored
16
.github/workflows/ci-perf-report.yaml
vendored
@@ -3,8 +3,10 @@ name: 'CI: Performance Report'
|
||||
on:
|
||||
push:
|
||||
branches: [main, core/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
|
||||
concurrency:
|
||||
group: perf-${{ github.ref }}
|
||||
@@ -14,20 +16,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
perf-tests:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
|
||||
15
.github/workflows/ci-size-data.yaml
vendored
15
.github/workflows/ci-size-data.yaml
vendored
@@ -16,21 +16,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
collect:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
35
.github/workflows/ci-tests-e2e.yaml
vendored
35
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -4,6 +4,7 @@ name: 'CI: Tests E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
merge_group:
|
||||
@@ -14,20 +15,36 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Detect whether e2e-relevant files changed. Required checks see "skipped"
|
||||
# (which counts as passing) when only docs/apps/storybook files are touched,
|
||||
# avoiding the stall that paths-ignore would cause.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
- name: Checkout repository
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/checkout@v6
|
||||
- name: Check for e2e-relevant changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
predicate-quantifier: 'every'
|
||||
filters: |
|
||||
e2e:
|
||||
- '**'
|
||||
- '!apps/**'
|
||||
- '!docs/**'
|
||||
- '!.storybook/**'
|
||||
- '!**/*.md'
|
||||
|
||||
setup:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
if: ${{ needs.changes.outputs.should_run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -177,7 +194,7 @@ jobs:
|
||||
merge-reports:
|
||||
needs: [changes, playwright-tests-chromium-sharded]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
@@ -216,7 +233,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check E2E results
|
||||
env:
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
|
||||
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
|
||||
BROWSERS: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
@@ -234,7 +251,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
needs.changes.outputs.should-run == 'true' &&
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
@@ -261,7 +278,7 @@ jobs:
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
needs.changes.outputs.should-run == 'true' &&
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
|
||||
47
.github/workflows/ci-tests-storybook.yaml
vendored
47
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -8,29 +8,10 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
|
||||
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -49,13 +30,8 @@ jobs:
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
@@ -91,15 +67,8 @@ jobs:
|
||||
|
||||
# Chromatic deployment only for version-bump-* branches or manual triggers
|
||||
chromatic-deployment:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true'))
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
@@ -138,15 +107,9 @@ jobs:
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [changes, storybook-build]
|
||||
needs: [storybook-build]
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
always()
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
15
.github/workflows/ci-tests-unit.yaml
vendored
15
.github/workflows/ci-tests-unit.yaml
vendored
@@ -4,8 +4,10 @@ name: 'CI: Tests Unit'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
@@ -13,20 +15,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
22
.github/workflows/ci-website-build.yaml
vendored
22
.github/workflows/ci-website-build.yaml
vendored
@@ -4,29 +4,23 @@ name: 'CI: Website Build'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
32
.github/workflows/ci-website-e2e.yaml
vendored
32
.github/workflows/ci-website-e2e.yaml
vendored
@@ -3,29 +3,25 @@ name: 'CI: Website E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
website-e2e:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
@@ -49,8 +45,6 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Run Playwright tests
|
||||
@@ -167,11 +161,7 @@ jobs:
|
||||
post-starting-comment:
|
||||
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
|
||||
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
|
||||
needs: changes
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -18,7 +18,6 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
|
||||
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
|
||||
@@ -87,8 +86,6 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Update screenshots
|
||||
@@ -140,10 +137,7 @@ jobs:
|
||||
name: 'Update Website Screenshots'
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
throw e
|
||||
}
|
||||
core.info('Label "Update Website Screenshots" was already removed')
|
||||
// Label may already be removed
|
||||
}
|
||||
|
||||
post-starting-comment:
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Skip in CI: the canonical knip check runs in ci-lint-format on every
|
||||
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
|
||||
# setup-comfyui-server, which contaminates knip's project glob with the
|
||||
# devtools copy under custom_nodes and produces false-positive failures.
|
||||
if [ -n "${CI-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run Knip with cache via package script
|
||||
pnpm knip 1>&2
|
||||
|
||||
|
||||
@@ -113,31 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
|
||||
```html
|
||||
<script
|
||||
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
|
||||
defer
|
||||
></script>
|
||||
<div
|
||||
class="hs-form-html"
|
||||
data-region="na2"
|
||||
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
|
||||
data-portal-id="244637579"
|
||||
></div>
|
||||
```
|
||||
|
||||
The localized `/zh-CN/contact` page uses the same portal and script with form
|
||||
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
|
||||
|
||||
This keeps submission handling, validation, anti-spam updates, and field
|
||||
configuration in HubSpot. The local implementation in
|
||||
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
|
||||
renders the documented embed container.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — Astro dev server
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -26,7 +26,6 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,33 +1,4 @@
|
||||
# robots.txt for comfy.org
|
||||
# Open to all crawlers — including AI/LLM bots — for maximum visibility
|
||||
# in AI-powered search, chat-based answer engines, and traditional search.
|
||||
# Granular UAs are listed explicitly to signal intent; rules are shared
|
||||
# via stacked user-agent records (RFC 9309 §2.2).
|
||||
|
||||
User-agent: *
|
||||
User-agent: Googlebot
|
||||
User-agent: Bingbot
|
||||
User-agent: DuckDuckBot
|
||||
User-agent: GPTBot
|
||||
User-agent: ChatGPT-User
|
||||
User-agent: OAI-SearchBot
|
||||
User-agent: Google-Extended
|
||||
User-agent: ClaudeBot
|
||||
User-agent: Claude-Web
|
||||
User-agent: anthropic-ai
|
||||
User-agent: PerplexityBot
|
||||
User-agent: Perplexity-User
|
||||
User-agent: Applebot
|
||||
User-agent: Applebot-Extended
|
||||
User-agent: Bytespider
|
||||
User-agent: Amazonbot
|
||||
User-agent: CCBot
|
||||
User-agent: Meta-ExternalAgent
|
||||
User-agent: Meta-ExternalFetcher
|
||||
User-agent: Diffbot
|
||||
Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import HubspotFormEmbed from './HubspotFormEmbed.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
@@ -16,6 +17,30 @@ function tk(suffix: string): TranslationKey {
|
||||
return `contact.form.${suffix}` as TranslationKey
|
||||
}
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const company = ref('')
|
||||
const phone = ref('')
|
||||
const selectedPackage = ref('')
|
||||
const comfyUsage = ref('')
|
||||
const lookingFor = ref('')
|
||||
|
||||
const packageOptions = [
|
||||
'packageIndividual',
|
||||
'packageTeams',
|
||||
'packageEnterprise'
|
||||
] as const
|
||||
|
||||
const usageOptions = [
|
||||
'usingYesProduction',
|
||||
'usingYesTesting',
|
||||
'usingNotYet',
|
||||
'usingOtherTools'
|
||||
] as const
|
||||
|
||||
const inputClass =
|
||||
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
|
||||
|
||||
const sectionRef = ref<HTMLElement>()
|
||||
const badgeRef = ref<HTMLElement>()
|
||||
const headingRef = ref<HTMLElement>()
|
||||
@@ -30,6 +55,10 @@ useHeroAnimation({
|
||||
video: formRef,
|
||||
parallax: false
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
// TODO: implement form submission
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -76,7 +105,160 @@ useHeroAnimation({
|
||||
|
||||
<!-- Right column: form -->
|
||||
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
|
||||
<HubspotFormEmbed :locale />
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<!-- First Name + Last Name -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('firstName'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('firstNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lastName'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('lastNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company + Phone -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('company'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="company"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('companyPlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('phone'), locale) }}
|
||||
</label>
|
||||
<input v-model="phone" type="tel" :class="inputClass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package selection -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('packageQuestion'), locale) }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="opt in packageOptions"
|
||||
:key="opt"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
|
||||
selectedPackage === opt
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="selectedPackage"
|
||||
type="radio"
|
||||
name="package"
|
||||
:value="opt"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
selectedPackage === opt
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-primary-warm-gray/40'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="selectedPackage === opt"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
{{ t(tk(opt), locale) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfy usage -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('usingComfy'), locale) }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<label
|
||||
v-for="opt in usageOptions"
|
||||
:key="opt"
|
||||
class="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
comfyUsage === opt
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="comfyUsage === opt"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="comfyUsage"
|
||||
type="radio"
|
||||
:value="opt"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(tk(opt), locale) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Looking for -->
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lookingFor'), locale) }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="lookingFor"
|
||||
:placeholder="t(tk('lookingForPlaceholder'), locale)"
|
||||
:class="cn(inputClass, 'min-h-24 resize-y')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div>
|
||||
<BrandButton type="submit" variant="outline" size="sm">
|
||||
{{ t(tk('submit'), locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const HUBSPOT_CONTACT_PORTAL_ID = '244637579'
|
||||
const HUBSPOT_CONTACT_REGION = 'na2'
|
||||
const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed'
|
||||
const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js`
|
||||
|
||||
const hubspotContactFormIds: Record<Locale, string> = {
|
||||
en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262',
|
||||
'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93'
|
||||
}
|
||||
|
||||
const hasEmbedLoadError = ref(false)
|
||||
const hubspotContactFormId = computed(() => hubspotContactFormIds[locale])
|
||||
|
||||
const hubspotFormStyles: Record<`--${string}`, string> = {
|
||||
'--hsf-global__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-global__color': '#c2bfb9',
|
||||
'--hsf-background__background-color': '#211927',
|
||||
'--hsf-background__border-width': '0',
|
||||
'--hsf-background__padding': '0',
|
||||
'--hsf-button__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-button__font-size': '14px',
|
||||
'--hsf-button__color': '#211927',
|
||||
'--hsf-button__background-color': '#f2ff59',
|
||||
'--hsf-button__border-radius': '16px',
|
||||
'--hsf-button__padding': '10px 24px',
|
||||
'--hsf-richtext__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-richtext__color': '#c2bfb9',
|
||||
'--hsf-heading__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-heading__color': '#c2bfb9',
|
||||
'--hsf-field-label__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-label__font-size': '12px',
|
||||
'--hsf-field-label__color': '#c2bfb9',
|
||||
'--hsf-field-description__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-description__color': '#c2bfb9',
|
||||
'--hsf-field-footer__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-footer__color': '#c2bfb9',
|
||||
'--hsf-field-input__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-input__color': '#c2bfb9',
|
||||
'--hsf-field-input__background-color': '#2a2230',
|
||||
'--hsf-field-input__placeholder-color': '#585159',
|
||||
'--hsf-field-input__border-color': '#3b3539',
|
||||
'--hsf-field-input__border-width': '1px',
|
||||
'--hsf-field-input__border-style': 'solid',
|
||||
'--hsf-field-input__border-radius': '16px',
|
||||
'--hsf-field-input__padding': '16px',
|
||||
'--hsf-field-textarea__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-textarea__color': '#c2bfb9',
|
||||
'--hsf-field-textarea__background-color': '#2a2230',
|
||||
'--hsf-field-textarea__placeholder-color': '#585159',
|
||||
'--hsf-field-textarea__border-color': '#3b3539',
|
||||
'--hsf-field-textarea__border-width': '1px',
|
||||
'--hsf-field-textarea__border-style': 'solid',
|
||||
'--hsf-field-textarea__border-radius': '16px',
|
||||
'--hsf-field-textarea__padding': '16px',
|
||||
'--hsf-field-checkbox__color': '#c2bfb9',
|
||||
'--hsf-field-checkbox__background-color': '#2a2230',
|
||||
'--hsf-field-checkbox__border-color': '#464147',
|
||||
'--hsf-field-checkbox__border-width': '1px',
|
||||
'--hsf-field-checkbox__border-style': 'solid',
|
||||
'--hsf-field-radio__color': '#c2bfb9',
|
||||
'--hsf-field-radio__background-color': '#2a2230',
|
||||
'--hsf-field-radio__border-color': '#464147',
|
||||
'--hsf-field-radio__border-width': '1px',
|
||||
'--hsf-field-radio__border-style': 'solid',
|
||||
'--hsf-erroralert__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-infoalert__font-family': "'PP Formula', sans-serif"
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = HUBSPOT_CONTACT_SCRIPT_ID
|
||||
script.src = HUBSPOT_CONTACT_SCRIPT_SRC
|
||||
script.defer = true
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
hasEmbedLoadError.value = true
|
||||
script.remove()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
document.head.append(script)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[640px] w-full">
|
||||
<p
|
||||
v-if="hasEmbedLoadError"
|
||||
class="text-primary-comfy-canvas text-sm/6"
|
||||
role="status"
|
||||
>
|
||||
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
|
||||
<a
|
||||
class="text-primary-comfy-yellow underline"
|
||||
href="mailto:hello@comfy.org"
|
||||
>
|
||||
hello@comfy.org
|
||||
</a>
|
||||
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
:key="hubspotContactFormId"
|
||||
class="hs-form-html"
|
||||
:style="hubspotFormStyles"
|
||||
:data-region="HUBSPOT_CONTACT_REGION"
|
||||
:data-form-id="hubspotContactFormId"
|
||||
:data-portal-id="HUBSPOT_CONTACT_PORTAL_ID"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3298,13 +3298,82 @@ const translations = {
|
||||
en: 'Find your answer here',
|
||||
'zh-CN': '在这里找到答案'
|
||||
},
|
||||
'contact.form.embedLoadErrorPrefix': {
|
||||
en: 'Unable to load the contact form. Email us at',
|
||||
'zh-CN': '联系表单无法加载。请发送邮件至'
|
||||
'contact.form.firstName': {
|
||||
en: 'First name',
|
||||
'zh-CN': '名'
|
||||
},
|
||||
'contact.form.embedLoadErrorSuffix': {
|
||||
en: "and we'll route your request.",
|
||||
'zh-CN': '我们会为您处理请求。'
|
||||
'contact.form.lastName': {
|
||||
en: 'Last Name',
|
||||
'zh-CN': '姓'
|
||||
},
|
||||
'contact.form.company': {
|
||||
en: 'Company',
|
||||
'zh-CN': '公司'
|
||||
},
|
||||
'contact.form.phone': {
|
||||
en: 'Phone Number (optional)',
|
||||
'zh-CN': '电话号码(可选)'
|
||||
},
|
||||
'contact.form.packageQuestion': {
|
||||
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
|
||||
'zh-CN':
|
||||
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K)、个人套餐或团队套餐?'
|
||||
},
|
||||
'contact.form.packageIndividual': {
|
||||
en: 'INDIVIDUAL',
|
||||
'zh-CN': '个人'
|
||||
},
|
||||
'contact.form.packageTeams': {
|
||||
en: 'TEAMS',
|
||||
'zh-CN': '团队'
|
||||
},
|
||||
'contact.form.packageEnterprise': {
|
||||
en: 'ENTERPRISE',
|
||||
'zh-CN': '企业'
|
||||
},
|
||||
'contact.form.usingComfy': {
|
||||
en: 'Are you /your team currently using Comfy?',
|
||||
'zh-CN': '您/您的团队目前是否在使用 Comfy?'
|
||||
},
|
||||
'contact.form.usingYesProduction': {
|
||||
en: 'Yes, in production',
|
||||
'zh-CN': '是,在生产环境中'
|
||||
},
|
||||
'contact.form.usingYesTesting': {
|
||||
en: 'Yes, testing / experimenting',
|
||||
'zh-CN': '是,测试/实验中'
|
||||
},
|
||||
'contact.form.usingNotYet': {
|
||||
en: 'Not yet, evaluating',
|
||||
'zh-CN': '尚未使用,评估中'
|
||||
},
|
||||
'contact.form.usingOtherTools': {
|
||||
en: 'Not using Comfy yet, but using other GenAI tools',
|
||||
'zh-CN': '尚未使用 Comfy,但在使用其他 GenAI 工具'
|
||||
},
|
||||
'contact.form.lookingFor': {
|
||||
en: 'What are you looking for?',
|
||||
'zh-CN': '您在寻找什么?'
|
||||
},
|
||||
'contact.form.lookingForPlaceholder': {
|
||||
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
|
||||
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
|
||||
},
|
||||
'contact.form.submit': {
|
||||
en: 'SUBMIT',
|
||||
'zh-CN': '提交'
|
||||
},
|
||||
'contact.form.firstNamePlaceholder': {
|
||||
en: 'Jane',
|
||||
'zh-CN': 'Jane'
|
||||
},
|
||||
'contact.form.lastNamePlaceholder': {
|
||||
en: 'Smith',
|
||||
'zh-CN': 'Smith'
|
||||
},
|
||||
'contact.form.companyPlaceholder': {
|
||||
en: 'jane@acme.org',
|
||||
'zh-CN': 'jane@acme.org'
|
||||
},
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
|
||||
@@ -133,15 +133,9 @@ const websiteJsonLd = {
|
||||
<script>
|
||||
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
|
||||
import { ScrollTrigger } from '../scripts/gsapSetup'
|
||||
import { initPostHog, capturePageview } from '../scripts/posthog'
|
||||
|
||||
initSmoothScroll()
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
initPostHog()
|
||||
document.addEventListener('astro:page-load', capturePageview)
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
ScrollTrigger.refresh()
|
||||
})
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
const POSTHOG_API_HOST =
|
||||
import.meta.env.PUBLIC_POSTHOG_API_HOST ?? 'https://t.comfy.org'
|
||||
const POSTHOG_UI_HOST =
|
||||
import.meta.env.PUBLIC_POSTHOG_UI_HOST ?? 'https://us.posthog.com'
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initPostHog() {
|
||||
if (initialized || typeof window === 'undefined' || !POSTHOG_KEY) return
|
||||
try {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_API_HOST,
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
console.error('PostHog init failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function capturePageview() {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('$pageview')
|
||||
} catch (error) {
|
||||
console.error('PostHog pageview capture failed', error)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
else process.env.WEBSITE_GITHUB_STARS_OVERRIDE = savedOverride
|
||||
})
|
||||
|
||||
it('uses the build-time override without calling GitHub', async () => {
|
||||
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when the build-time override is malformed', async () => {
|
||||
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStarCount', () => {
|
||||
it('formats the visual-test override to match committed snapshots', () => {
|
||||
expect(formatStarCount(110000)).toBe('110K')
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,6 @@ export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
@@ -28,17 +25,3 @@ export function formatStarCount(count: number): string {
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
function readGitHubStarsOverride(): number | undefined {
|
||||
const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
if (rawCount === undefined || rawCount === '') return undefined
|
||||
|
||||
const count = Number(rawCount)
|
||||
if (!Number.isSafeInteger(count) || count < 0) {
|
||||
throw new Error(
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -96,17 +96,6 @@ pnpm test:browser:local # Run all tests
|
||||
pnpm test:browser:local widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Slowing the browser down for debugging
|
||||
|
||||
When running with `--headed` (or `--ui`), set `SLOW_MO` to a millisecond delay
|
||||
to slow every Playwright action down so you can watch what is happening. The
|
||||
delay only applies when `PLAYWRIGHT_LOCAL` is set (the default for the
|
||||
`pnpm test:browser:local` script).
|
||||
|
||||
```bash
|
||||
SLOW_MO=250 pnpm test:browser:local --headed widget.spec.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Browser tests in this project follow a specific organization pattern:
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Preview3D",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 600],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3D",
|
||||
"Last Time Model File": "nonexistent_model.glb"
|
||||
},
|
||||
"widgets_values": ["nonexistent_model.glb"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "offset": [0, 0], "scale": 1 } },
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Mouse } from '@playwright/test'
|
||||
import type { Mouse } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
@@ -72,22 +72,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async resizeByDragging(
|
||||
element: Locator,
|
||||
{ x, y }: { x?: number; y?: number }
|
||||
) {
|
||||
const elementBox = await element.boundingBox()
|
||||
if (!elementBox) throw new Error('element should have layout')
|
||||
|
||||
const cx = elementBox.x + elementBox.width / 2
|
||||
const cy = elementBox.y + elementBox.height / 2
|
||||
|
||||
await this.dragAndDrop(
|
||||
{ x: cx, y: cy },
|
||||
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
|
||||
)
|
||||
}
|
||||
|
||||
//#region Pass-through
|
||||
async click(...args: Parameters<Mouse['click']>) {
|
||||
return await this.mouse.click(...args)
|
||||
|
||||
@@ -30,13 +30,6 @@ export class VueNodeHelpers {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inner wrapper element of a Vue node.
|
||||
*/
|
||||
getNodeInnerWrapper(nodeId: string): Locator {
|
||||
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
@@ -126,9 +119,10 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -139,15 +133,6 @@ export class VueNodeHelpers {
|
||||
)
|
||||
}
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ export class ComfyNodeSearchBoxV2 {
|
||||
readonly filterChips: Locator
|
||||
readonly noResults: Locator
|
||||
readonly nodeIdBadge: Locator
|
||||
readonly sidebarToggle: Locator
|
||||
readonly sidebarBackdrop: Locator
|
||||
readonly filterChipsScroll: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
@@ -31,11 +28,6 @@ export class ComfyNodeSearchBoxV2 {
|
||||
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
|
||||
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
|
||||
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
|
||||
this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle)
|
||||
this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop)
|
||||
this.filterChipsScroll = this.dialog.getByTestId(
|
||||
searchBoxV2.filterChipsScroll
|
||||
)
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class BoundingBoxCoordinate {
|
||||
public readonly root: Locator
|
||||
public readonly input: Locator
|
||||
public readonly incrementButton: Locator
|
||||
public readonly decrementButton: Locator
|
||||
|
||||
constructor(root: Locator) {
|
||||
this.root = root
|
||||
this.input = root.locator('input')
|
||||
this.incrementButton = root.getByTestId(TestIds.widgets.increment)
|
||||
this.decrementButton = root.getByTestId(TestIds.widgets.decrement)
|
||||
}
|
||||
|
||||
async type(value: string | number): Promise<void> {
|
||||
await this.input.fill(String(value))
|
||||
await this.input.press('Enter')
|
||||
}
|
||||
|
||||
async focus(): Promise<void> {
|
||||
await this.input.focus()
|
||||
}
|
||||
|
||||
async increment(): Promise<void> {
|
||||
await this.incrementButton.click()
|
||||
}
|
||||
|
||||
async decrement(): Promise<void> {
|
||||
await this.decrementButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class WidgetBoundingBoxFixture {
|
||||
public readonly root: Locator
|
||||
public readonly x: BoundingBoxCoordinate
|
||||
public readonly y: BoundingBoxCoordinate
|
||||
public readonly width: BoundingBoxCoordinate
|
||||
public readonly height: BoundingBoxCoordinate
|
||||
|
||||
constructor(parent: Locator) {
|
||||
this.root = parent.getByTestId('bounding-box')
|
||||
this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x'))
|
||||
this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y'))
|
||||
this.width = new BoundingBoxCoordinate(
|
||||
this.root.getByTestId('bounding-box-width')
|
||||
)
|
||||
this.height = new BoundingBoxCoordinate(
|
||||
this.root.getByTestId('bounding-box-height')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,9 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
*/
|
||||
export function buildKSamplerError(
|
||||
type: NodeError['errors'][number]['type'],
|
||||
inputName: string,
|
||||
message: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type,
|
||||
message,
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for simulating prompt execution in e2e tests.
|
||||
*/
|
||||
@@ -42,23 +16,13 @@ export class ExecutionHelper {
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
private readonly ws?: WebSocketRoute
|
||||
private readonly ws: WebSocketRoute
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.command = comfyPage.command
|
||||
this.assets = comfyPage.assets
|
||||
}
|
||||
|
||||
private requireWs(): WebSocketRoute {
|
||||
if (!this.ws) {
|
||||
throw new Error(
|
||||
'ExecutionHelper was constructed without a WebSocketRoute; ' +
|
||||
'pass `ws` to use methods that send WS frames.'
|
||||
)
|
||||
}
|
||||
return this.ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||
* the synthetic job ID.
|
||||
@@ -75,7 +39,7 @@ export class ExecutionHelper {
|
||||
})
|
||||
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -96,31 +60,6 @@ export class ExecutionHelper {
|
||||
return jobId
|
||||
}
|
||||
|
||||
async mockValidationFailure(
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Promise<void> {
|
||||
const response: PromptResponse = {
|
||||
node_errors: nodeErrors,
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Prompt outputs failed validation',
|
||||
details: ''
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||
@@ -150,12 +89,12 @@ export class ExecutionHelper {
|
||||
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||
|
||||
this.requireWs().send(Buffer.from(buf))
|
||||
this.ws.send(Buffer.from(buf))
|
||||
}
|
||||
|
||||
/** Send `execution_start` WS event. */
|
||||
executionStart(jobId: string): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -165,7 +104,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `executing` WS event to signal which node is currently running. */
|
||||
executing(jobId: string, nodeId: string | null): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { prompt_id: jobId, node: nodeId }
|
||||
@@ -179,7 +118,7 @@ export class ExecutionHelper {
|
||||
nodeId: string,
|
||||
output: Record<string, unknown>
|
||||
): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executed',
|
||||
data: {
|
||||
@@ -194,7 +133,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_success` WS event. */
|
||||
executionSuccess(jobId: string): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_success',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -204,7 +143,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_error` WS event. */
|
||||
executionError(jobId: string, nodeId: string, message: string): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_error',
|
||||
data: {
|
||||
@@ -222,7 +161,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||
@@ -262,7 +201,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `status` WS event to update queue count. */
|
||||
status(queueRemaining: number): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||
|
||||
@@ -210,8 +210,7 @@ export const TestIds = {
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list'
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
@@ -262,9 +261,6 @@ export const TestIds = {
|
||||
chipDelete: 'chip-delete',
|
||||
noResults: 'no-results',
|
||||
nodeIdBadge: 'node-id-badge',
|
||||
sidebarToggle: 'toggle-category-sidebar',
|
||||
sidebarBackdrop: 'sidebar-backdrop',
|
||||
filterChipsScroll: 'filter-chips-scroll',
|
||||
category: (id: string) => `category-${id}`,
|
||||
rootCategory: (id: string) => `search-category-${id}`,
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { cleanupFakeModel } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -282,57 +282,6 @@ test.describe('Load3D', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load3D silent 404 on missing output model', () => {
|
||||
test('Does not show an error toast when the output model file is missing (404)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept model fetch and return 404 to simulate a missing output file
|
||||
// (e.g. shared workflow opened on a machine that never ran it)
|
||||
await comfyPage.page.route('**/view?**', (route) =>
|
||||
route.fulfill({ status: 404, body: 'Not Found' })
|
||||
)
|
||||
|
||||
// This workflow has a Preview3D node with Last Time Model File set,
|
||||
// triggering the loadFolder: 'output' + silentOnNotFound: true path.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
// Wait for the 404 response before asserting — gives the load attempt time
|
||||
// to complete without using waitForTimeout
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
|
||||
await responsePromise
|
||||
|
||||
await expect(
|
||||
comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Shows an error toast when a non-404 error occurs loading the output model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept with a 500 to simulate a real server error (not 404) — toast must appear
|
||||
await comfyPage.page.route('**/view?**', (route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
|
||||
await responsePromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.toast.visibleToasts
|
||||
.filter({ hasText: 'Error loading model' })
|
||||
.count(),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load3D initialization failure', () => {
|
||||
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -267,65 +267,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
if (mode === 'vue') {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
} else {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
}
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
const subgraphId = await comfyPage.subgraph.getActiveGraphId()
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.state.ghostNodeId != null
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), {
|
||||
message:
|
||||
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getActiveGraphId())
|
||||
.toBe(subgraphId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
|
||||
)
|
||||
.toBeNull()
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,151 +125,4 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Category sidebar', () => {
|
||||
test('Sidebar toggle hides and shows the category sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
const samplingCategory = searchBoxV2.categoryButton('sampling')
|
||||
await expect(samplingCategory).toBeVisible()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(samplingCategory).toBeHidden()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect(samplingCategory).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
// Narrow viewport so the chips overflow the filter bar
|
||||
await comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
await searchBoxV2.open()
|
||||
|
||||
const scrollEl = searchBoxV2.filterChipsScroll
|
||||
const dims = await scrollEl.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth
|
||||
}))
|
||||
expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth)
|
||||
|
||||
await scrollEl.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
|
||||
// The toggle lives outside the scroll container, so even when the
|
||||
// chips scroll hundreds of px it must remain visible in the viewport.
|
||||
await expect(searchBoxV2.sidebarToggle).toBeInViewport()
|
||||
})
|
||||
|
||||
test('@mobile Sidebar is collapsed by default on mobile', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
|
||||
})
|
||||
|
||||
test('@mobile Clicking outside the sidebar closes it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeVisible()
|
||||
await expect(searchBoxV2.sidebarBackdrop).toBeVisible()
|
||||
|
||||
// The backdrop spans the full content area, but the sidebar (z-20)
|
||||
// covers its left ~208px (w-52). Click past that to land on the
|
||||
// backdrop rather than the sidebar.
|
||||
await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } })
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
|
||||
await expect(searchBoxV2.sidebarBackdrop).toBeHidden()
|
||||
})
|
||||
|
||||
test('@mobile Focusing the search input closes the sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
|
||||
await searchBoxV2.input.focus()
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
test('Sidebar state across mobile/desktop resizes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const switchToDesktop = () =>
|
||||
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
|
||||
const switchToMobile = () =>
|
||||
comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
value
|
||||
)
|
||||
|
||||
await switchToDesktop()
|
||||
await searchBoxV2.open()
|
||||
await expectExpanded('true')
|
||||
|
||||
await switchToMobile()
|
||||
await expectExpanded('false')
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await switchToDesktop()
|
||||
await expectExpanded('true')
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await switchToMobile()
|
||||
await expectExpanded('false')
|
||||
|
||||
await switchToDesktop()
|
||||
await expectExpanded('false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,21 +4,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
export async function enableErrorsOverlay(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Dismiss the error overlay (the floating dialog with the dismiss button). */
|
||||
export async function dismissErrorOverlay(comfyPage: ComfyPage): Promise<void> {
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
}
|
||||
|
||||
export async function loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
cleanupFakeModel,
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { Locator, Page, Request } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const TOTAL_MOCK_JOBS = 20
|
||||
const overflowJobsListRoutePattern = '**/api/jobs?*'
|
||||
|
||||
function isHistoryJobsRequest(url: string): boolean {
|
||||
if (!url.includes('/api/jobs')) return false
|
||||
const params = new URL(url).searchParams
|
||||
const statuses = (params.get('status') ?? '').split(',')
|
||||
return statuses.includes('completed')
|
||||
}
|
||||
|
||||
async function captureNextHistoryRequest(
|
||||
comfyPage: ComfyPage,
|
||||
exec: ExecutionHelper
|
||||
): Promise<Request> {
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
(req) => isHistoryJobsRequest(req.url()),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
exec.status(0)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
function getJobListResults(page: Page): Locator {
|
||||
return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]')
|
||||
}
|
||||
|
||||
test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Queue.MaxHistoryItems', () => {
|
||||
test.describe('limit query parameter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(
|
||||
createMockJobs(TOTAL_MOCK_JOBS)
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('limit query parameter on /api/jobs reflects the setting', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const TARGET_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
TARGET_LIMIT
|
||||
)
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const request = await captureNextHistoryRequest(comfyPage, exec)
|
||||
const url = new URL(request.url())
|
||||
expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
test('queue panel caps history items to the configured number', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
// Add a mock route that returns all jobs regardless of the request's `limit` param
|
||||
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
|
||||
await comfyPage.page.route(
|
||||
overflowJobsListRoutePattern,
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (!url.searchParams.get('status')?.includes('completed')) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
const response = {
|
||||
jobs: overflowJobs,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: overflowJobs.length,
|
||||
total: overflowJobs.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const VISIBLE_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
VISIBLE_LIMIT
|
||||
)
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await captureNextHistoryRequest(comfyPage, exec)
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
const jobs = getJobListResults(comfyPage.page)
|
||||
await expect(jobs.first()).toBeVisible()
|
||||
await expect(jobs).toHaveCount(VISIBLE_LIMIT)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -42,11 +42,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
// Selection toolbox should be visible with multiple nodes selected
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
// Border is now drawn on canvas, check via screenshot
|
||||
// Allow small anti-aliasing variance on the canvas-drawn selection border
|
||||
// (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-multiple-nodes-border.png',
|
||||
{ maxDiffPixels: 100 }
|
||||
'selection-toolbox-multiple-nodes-border.png'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -40,6 +42,31 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
@@ -498,4 +525,29 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,7 +121,10 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'vue-groups-create-group.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should allow fitting group to contents', async ({ comfyPage }) => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,25 +1,9 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
enableErrorsOverlay
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -27,202 +11,24 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'UNKNOWN NODE' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('should display error state when node causes execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
const raiseErrorId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('Raise Error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(raiseErrorId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test.describe('validation errors', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
test('shows error ring when a validation error is returned for a node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'value_bigger_than_max',
|
||||
'steps',
|
||||
'steps: 99999 is bigger than max 10000'
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user edits an out-of-range number widget back into range', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
await test.step('queue with out-of-range steps to surface the error', async () => {
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'value_bigger_than_max',
|
||||
'steps',
|
||||
'steps: 99999 is bigger than max 10000'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('edit steps widget so the new value is within range', async () => {
|
||||
const stepsWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
'KSampler',
|
||||
'steps'
|
||||
)
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
// ScrubableNumberInput commits on blur — explicit blur avoids a race
|
||||
// with the keyup-Enter handler in case Enter is consumed elsewhere.
|
||||
await controls.input.fill('25')
|
||||
await controls.input.blur()
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user picks a different combo option', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
await test.step('queue with invalid sampler to surface the error', async () => {
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'value_not_in_list',
|
||||
'sampler_name',
|
||||
'sampler_name: bogus_sampler is not in list'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('select a different sampler option', async () => {
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
'KSampler',
|
||||
'sampler_name',
|
||||
'dpmpp_2m'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when an interior node is missing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
|
||||
'Subgraph with Missing Node'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when an interior node has a missing model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
|
||||
'Subgraph with Missing Model'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when an interior node fails execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph parent must mount before injecting WS execution_error'
|
||||
).toBeVisible()
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
exec.executionError(
|
||||
'mocked-prompt',
|
||||
INNER_EXECUTION_ID,
|
||||
'boom inside the subgraph'
|
||||
)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when interior node has a validation error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Validation errors are keyed by execution id, so an interior error
|
||||
// ("2:1") must propagate the ring up to the root-level subgraph
|
||||
// container ("2") via errorAncestorExecutionIds.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
await expect(innerWrapper).toBeVisible()
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'value_bigger_than_max',
|
||||
'steps',
|
||||
'steps: 99999 is bigger than max 10000'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
const raiseErrorNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'Raise Error' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -2,10 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
|
||||
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
|
||||
|
||||
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -24,11 +20,15 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(comfyPage: ComfyPage) {
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(comfyPage: ComfyPage) {
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
@@ -58,41 +58,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText(SHOW_ADVANCED_INPUTS).click()
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText(HIDE_ADVANCED_INPUTS).click()
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should hide advanced footer button while collapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
|
||||
const vueNode =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeHidden()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -113,6 +92,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
|
||||
await expect(node.getByText('Show advanced inputs')).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await test.step('setup', async () => {
|
||||
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
|
||||
})
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
)
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
|
||||
const { width, height } = (await legacyWidget.boundingBox())!
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
|
||||
const legacyWidgetRef = await nodeRef.getWidget(0)
|
||||
expect(await legacyWidgetRef.getValue()).toBe(0)
|
||||
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
|
||||
await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1)
|
||||
await legacyWidget.click({ position: { x: width - 20, y: height / 2 } })
|
||||
await expect.poll(() => legacyWidgetRef.getValue()).toBe(0)
|
||||
})
|
||||
|
||||
await test.step('Resize to update width', async () => {
|
||||
const initialWidth = await getWidth()
|
||||
expect(initialWidth).toBeGreaterThan(0)
|
||||
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
await expect(gutter).toBeVisible()
|
||||
await comfyMouse.resizeByDragging(gutter, { x: -200 })
|
||||
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
|
||||
const intermediateWidth = await getWidth()
|
||||
|
||||
await comfyMouse.resizeByDragging(gutter, { x: 100 })
|
||||
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
|
||||
})
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetBoundingBoxFixture } from '@e2e/fixtures/components/WidgetBoundingBox'
|
||||
|
||||
const NODE_ID = '1'
|
||||
|
||||
test.describe('Widget Bounding Box', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
})
|
||||
|
||||
test(
|
||||
'Renders all four coordinate inputs with workflow values',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await expect(boundingBox.root).toBeVisible()
|
||||
await expect(boundingBox.x.input).toHaveValue('0')
|
||||
await expect(boundingBox.y.input).toHaveValue('0')
|
||||
await expect(boundingBox.width.input).toHaveValue('512')
|
||||
await expect(boundingBox.height.input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
|
||||
test('Typing into each coordinate updates only that coordinate', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await test.step('type X', async () => {
|
||||
await boundingBox.x.type(25)
|
||||
await expect(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.y.input).toHaveValue('0')
|
||||
await expect.soft(boundingBox.width.input).toHaveValue('512')
|
||||
await expect.soft(boundingBox.height.input).toHaveValue('512')
|
||||
})
|
||||
|
||||
await test.step('type Y', async () => {
|
||||
await boundingBox.y.type(40)
|
||||
await expect(boundingBox.y.input).toHaveValue('40')
|
||||
await expect.soft(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.width.input).toHaveValue('512')
|
||||
await expect.soft(boundingBox.height.input).toHaveValue('512')
|
||||
})
|
||||
|
||||
await test.step('type Width', async () => {
|
||||
await boundingBox.width.type(200)
|
||||
await expect(boundingBox.width.input).toHaveValue('200')
|
||||
await expect.soft(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.y.input).toHaveValue('40')
|
||||
await expect.soft(boundingBox.height.input).toHaveValue('512')
|
||||
})
|
||||
|
||||
await test.step('type Height', async () => {
|
||||
await boundingBox.height.type(300)
|
||||
await expect(boundingBox.height.input).toHaveValue('300')
|
||||
await expect.soft(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.y.input).toHaveValue('40')
|
||||
await expect.soft(boundingBox.width.input).toHaveValue('200')
|
||||
})
|
||||
})
|
||||
|
||||
test('Negative X/Y values are clamped to min=0', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.x.type(50)
|
||||
await expect(boundingBox.x.input).toHaveValue('50')
|
||||
await boundingBox.x.type('-10')
|
||||
await expect(boundingBox.x.input).toHaveValue('0')
|
||||
|
||||
await boundingBox.y.type(75)
|
||||
await expect(boundingBox.y.input).toHaveValue('75')
|
||||
await boundingBox.y.type('-50')
|
||||
await expect(boundingBox.y.input).toHaveValue('0')
|
||||
})
|
||||
|
||||
test('Width/Height values below 1 are clamped to min=1', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.width.type(0)
|
||||
await expect(boundingBox.width.input).toHaveValue('1')
|
||||
|
||||
await boundingBox.height.type('-5')
|
||||
await expect(boundingBox.height.input).toHaveValue('1')
|
||||
})
|
||||
|
||||
test('Increment and decrement buttons change coordinate by step', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await test.step('increment X from 0 to 2', async () => {
|
||||
await boundingBox.x.increment()
|
||||
await boundingBox.x.increment()
|
||||
await expect(boundingBox.x.input).toHaveValue('2')
|
||||
})
|
||||
|
||||
await test.step('decrement X from 2 to 1', async () => {
|
||||
await boundingBox.x.decrement()
|
||||
await expect(boundingBox.x.input).toHaveValue('1')
|
||||
})
|
||||
|
||||
await test.step('decrement Width from 512 to 510', async () => {
|
||||
await boundingBox.width.decrement()
|
||||
await boundingBox.width.decrement()
|
||||
await expect(boundingBox.width.input).toHaveValue('510')
|
||||
})
|
||||
|
||||
await test.step('increment Height from 512 to 513', async () => {
|
||||
await boundingBox.height.increment()
|
||||
await expect(boundingBox.height.input).toHaveValue('513')
|
||||
})
|
||||
})
|
||||
|
||||
test('Arrow keys step the focused input; PageUp/PageDown step by 10', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.width.focus()
|
||||
|
||||
await boundingBox.width.input.press('ArrowUp')
|
||||
await expect(boundingBox.width.input).toHaveValue('513')
|
||||
|
||||
await boundingBox.width.input.press('ArrowDown')
|
||||
await boundingBox.width.input.press('ArrowDown')
|
||||
await expect(boundingBox.width.input).toHaveValue('511')
|
||||
|
||||
await boundingBox.width.input.press('PageUp')
|
||||
await expect(boundingBox.width.input).toHaveValue('521')
|
||||
|
||||
await boundingBox.width.input.press('PageDown')
|
||||
await expect(boundingBox.width.input).toHaveValue('511')
|
||||
})
|
||||
|
||||
test('Decrement button is disabled when value equals min', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await test.step('X at 0 disables decrement', async () => {
|
||||
await expect(boundingBox.x.input).toHaveValue('0')
|
||||
await expect(boundingBox.x.decrementButton).toBeDisabled()
|
||||
await expect(boundingBox.x.incrementButton).toBeEnabled()
|
||||
})
|
||||
|
||||
await test.step('Width at 1 disables decrement', async () => {
|
||||
await boundingBox.width.type(1)
|
||||
await expect(boundingBox.width.input).toHaveValue('1')
|
||||
await expect(boundingBox.width.decrementButton).toBeDisabled()
|
||||
await expect(boundingBox.width.incrementButton).toBeEnabled()
|
||||
})
|
||||
|
||||
await test.step('Incrementing X re-enables decrement', async () => {
|
||||
await boundingBox.x.increment()
|
||||
await expect(boundingBox.x.decrementButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test('Non-numeric input reverts to previous value on blur', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.x.type(42)
|
||||
await expect(boundingBox.x.input).toHaveValue('42')
|
||||
|
||||
await boundingBox.x.input.fill('not a number')
|
||||
await boundingBox.x.input.blur()
|
||||
await expect(boundingBox.x.input).toHaveValue('42')
|
||||
})
|
||||
})
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { Page, Request } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
function isUserdataWorkflowSave(request: Request): boolean {
|
||||
return (
|
||||
request.method() === 'POST' &&
|
||||
/\/api\/userdata\/workflows%2F[^?]+\.json/.test(request.url())
|
||||
)
|
||||
}
|
||||
|
||||
function collectSaves(page: Page): Disposable & { readonly saves: string[] } {
|
||||
const saves: string[] = []
|
||||
function onRequest(request: Request) {
|
||||
if (isUserdataWorkflowSave(request)) saves.push(request.url())
|
||||
}
|
||||
page.on('request', onRequest)
|
||||
return {
|
||||
saves,
|
||||
[Symbol.dispose]() {
|
||||
page.off('request', onRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSave(page: Page, timeout: number): Promise<boolean> {
|
||||
return page
|
||||
.waitForRequest(isUserdataWorkflowSave, { timeout })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the first node so the change tracker dispatches `graphChanged`.
|
||||
*/
|
||||
async function triggerGraphChange(comfyPage: ComfyPage): Promise<void> {
|
||||
const node = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!node) throw new Error('Default workflow expected to have a first node')
|
||||
const titlePos = await node.getTitlePosition()
|
||||
const absFrom = await comfyPage.canvasOps.toAbsolute(titlePos)
|
||||
const absTo = { x: absFrom.x + 120, y: absFrom.y + 120 }
|
||||
await comfyPage.canvasOps.dragAndDrop(absFrom, absTo)
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function setupAutoSaveAfterDelay(
|
||||
comfyPage: ComfyPage,
|
||||
delayMs: number
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow('autosave')
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', delayMs)
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'after delay')
|
||||
}
|
||||
|
||||
test.describe('Workflow settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Workflow.AutoSave', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
|
||||
})
|
||||
|
||||
test("'off' does not save modified workflow after delay", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('autosave')
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', 50)
|
||||
|
||||
await triggerGraphChange(comfyPage)
|
||||
|
||||
// Within a window an order of magnitude longer than AutoSaveDelay, the
|
||||
// off watcher must not write back.
|
||||
const sawSave = await waitForSave(comfyPage.page, 500)
|
||||
expect(
|
||||
sawSave,
|
||||
'AutoSave=off must not write back after a graph change'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("'after delay' saves the workflow after a graph change", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupAutoSaveAfterDelay(comfyPage, 100)
|
||||
|
||||
const savePromise = comfyPage.page.waitForRequest(
|
||||
isUserdataWorkflowSave,
|
||||
{ timeout: 4000 }
|
||||
)
|
||||
await triggerGraphChange(comfyPage)
|
||||
await savePromise
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Workflow.AutoSaveDelay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
|
||||
})
|
||||
|
||||
test('long delay defers save until at least the configured duration has elapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const LONG_DELAY_MS = 1000
|
||||
const EARLY_WINDOW_MS = 500
|
||||
|
||||
await setupAutoSaveAfterDelay(comfyPage, LONG_DELAY_MS)
|
||||
|
||||
using tracker = collectSaves(comfyPage.page)
|
||||
|
||||
await triggerGraphChange(comfyPage)
|
||||
|
||||
// No save fires within a window comfortably shorter than the delay.
|
||||
const sawEarlySave = await waitForSave(comfyPage.page, EARLY_WINDOW_MS)
|
||||
expect(
|
||||
sawEarlySave,
|
||||
`No save should fire within ${EARLY_WINDOW_MS}ms when the configured delay is ${LONG_DELAY_MS}ms`
|
||||
).toBe(false)
|
||||
|
||||
// Eventually the save does fire.
|
||||
await comfyPage.page.waitForRequest(isUserdataWorkflowSave, {
|
||||
timeout: 3000
|
||||
})
|
||||
expect(tracker.saves).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
|
||||
async function getSerializedNodeIds(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeId[]> {
|
||||
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
|
||||
(n) => n.id
|
||||
)
|
||||
}
|
||||
|
||||
function ascendingById(ids: NodeId[]): NodeId[] {
|
||||
return [...ids].sort((a, b) => Number(a) - Number(b))
|
||||
}
|
||||
|
||||
test('false preserves the graph insertion order', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
false
|
||||
)
|
||||
const ids = await getSerializedNodeIds(comfyPage)
|
||||
|
||||
expect(ids, 'default workflow nodes already sorted').not.toEqual(
|
||||
ascendingById(ids)
|
||||
)
|
||||
})
|
||||
|
||||
test('true sorts nodes by id ascending', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
true
|
||||
)
|
||||
const ids = await getSerializedNodeIds(comfyPage)
|
||||
expect(ids).toEqual(ascendingById(ids))
|
||||
})
|
||||
|
||||
test('toggling sort preserves node set in both workflow JSON and API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
false
|
||||
)
|
||||
const expectedIds = ascendingById(await getSerializedNodeIds(comfyPage))
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
true
|
||||
)
|
||||
|
||||
// Workflow JSON nodes (the surface controlled by SortNodeIdOnSave) must
|
||||
// still contain the same set of ids — sort changes order, not membership.
|
||||
expect(ascendingById(await getSerializedNodeIds(comfyPage))).toEqual(
|
||||
expectedIds
|
||||
)
|
||||
|
||||
// The API prompt is independently derived from execution order, but it
|
||||
// must enumerate the same node set regardless of the sort flag.
|
||||
const apiPrompt: ComfyApiWorkflow =
|
||||
await comfyPage.workflow.getExportedWorkflow({ api: true })
|
||||
expect(ascendingById(Object.keys(apiPrompt).map(Number))).toEqual(
|
||||
expectedIds
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -257,8 +257,6 @@ it('should validate node definition', () => {
|
||||
|
||||
## Mocking Composables with Reactive State
|
||||
|
||||
> **Don't mock `vue-i18n`.** Mount with a real `createI18n` plugin instance instead — see [Don't Mock `vue-i18n` in `vitest-patterns.md`](./vitest-patterns.md#dont-mock-vue-i18n--use-a-real-plugin). This section applies to composables you own.
|
||||
|
||||
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
|
||||
|
||||
### Rules
|
||||
|
||||
@@ -30,42 +30,9 @@ describe('MyStore', () => {
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## Don't Mock `vue-i18n` — Use a Real Plugin
|
||||
## i18n in Component Tests
|
||||
|
||||
Mount with a real `createI18n` instance instead of mocking `vue-i18n`. The plugin is cheap, owned by a third party (don't mock what you don't own), and a real instance exercises the same translation key resolution and pluralization logic that production uses.
|
||||
|
||||
This applies to **all tests** that touch a component or composable calling `useI18n()` — not just component tests.
|
||||
|
||||
```typescript
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} } // empty — assertions key off the translation key, not the rendered string
|
||||
})
|
||||
|
||||
// Component tests: pass via global plugins
|
||||
mount(MyComponent, { global: { plugins: [i18n] } })
|
||||
|
||||
// Composable tests: provide via a host component (see useMediaAssetActions.test.ts pattern)
|
||||
const app = createApp(HostComponent)
|
||||
app.use(i18n)
|
||||
```
|
||||
|
||||
Real example: [`src/components/searchbox/v2/__test__/testUtils.ts`](../../src/components/searchbox/v2/__test__/testUtils.ts) exports a shared `testI18n` instance.
|
||||
|
||||
### Asserting on translation keys
|
||||
|
||||
With empty messages, `t('foo.bar')` returns `'foo.bar'` (the key). Assert against the key directly — no need to mock `t`:
|
||||
|
||||
```typescript
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'mediaAsset.selection.exportStarted' })
|
||||
)
|
||||
```
|
||||
|
||||
For pluralization / interpolation arguments, spy on the consumer (e.g. the toast `add` fn) and inspect the captured payload, rather than spying on `t` itself.
|
||||
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
|
||||
@@ -55,9 +55,7 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
'tools/devtools/web/**'
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.15",
|
||||
"version": "1.44.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -83,7 +83,6 @@
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
@@ -114,7 +113,6 @@
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
|
||||
@@ -27,23 +27,6 @@
|
||||
--color-smoke-700: #a0a0a0;
|
||||
--color-smoke-800: #8a8a8a;
|
||||
|
||||
/* Plum */
|
||||
--color-plum-300: #afa3db;
|
||||
--color-plum-400: #8d7fc5;
|
||||
--color-plum-500: #6b5ca8;
|
||||
--color-plum-600: #49378b;
|
||||
|
||||
/* Ink */
|
||||
--color-ink-100: #5c5362;
|
||||
--color-ink-200: #4f4754;
|
||||
--color-ink-300: #413b45;
|
||||
--color-ink-400: #353139;
|
||||
--color-ink-500: #312c34;
|
||||
--color-ink-600: #29252c;
|
||||
--color-ink-700: #232025;
|
||||
--color-ink-800: #19161a;
|
||||
--color-ink-900: #151317;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
--color-sand-300: #888682;
|
||||
--color-sand-400: #eed7ac;
|
||||
|
||||
--color-slate-100: #9c9eab;
|
||||
--color-slate-200: #9fa2bd;
|
||||
--color-slate-300: #5b5e7d;
|
||||
|
||||
--color-azure-300: #78bae9;
|
||||
--color-azure-400: #31b9f4;
|
||||
--color-azure-600: #0b8ce9;
|
||||
@@ -49,6 +53,7 @@
|
||||
|
||||
--color-jade-400: #47e469;
|
||||
--color-jade-600: #00cd72;
|
||||
--color-graphite-400: #9c9eab;
|
||||
|
||||
--color-gold-400: #fcbf64;
|
||||
--color-gold-500: #fdab34;
|
||||
@@ -203,7 +208,7 @@
|
||||
--node-component-slot-dot-outline-opacity: 5%;
|
||||
--node-component-slot-dot-outline: var(--color-black);
|
||||
--node-component-slot-text: var(--color-ash-800);
|
||||
--node-component-surface-highlight: var(--color-smoke-800);
|
||||
--node-component-surface-highlight: var(--color-ash-500);
|
||||
--node-component-surface-hovered: var(--color-smoke-200);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-white);
|
||||
@@ -222,7 +227,7 @@
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-azure-600);
|
||||
|
||||
--text-secondary: var(--color-smoke-800);
|
||||
--text-secondary: var(--color-ash-500);
|
||||
--text-primary: var(--color-charcoal-700);
|
||||
--input-surface: rgb(0 0 0 / 0.15);
|
||||
|
||||
@@ -259,7 +264,7 @@
|
||||
--secondary-background-selected
|
||||
);
|
||||
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
|
||||
--component-node-widget-background-highlighted: var(--color-smoke-800);
|
||||
--component-node-widget-background-highlighted: var(--color-ash-500);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-400);
|
||||
|
||||
@@ -339,19 +344,19 @@
|
||||
--node-component-border-error: var(--color-danger-100);
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-icon: var(--color-slate-300);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
--node-component-slot-dot-outline: var(--color-white);
|
||||
--node-component-slot-text: var(--color-smoke-700);
|
||||
--node-component-surface-highlight: var(--color-smoke-800);
|
||||
--node-component-slot-text: var(--color-slate-200);
|
||||
--node-component-surface-highlight: var(--color-slate-100);
|
||||
--node-component-surface-hovered: var(--color-charcoal-600);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-charcoal-600);
|
||||
--node-component-tooltip: var(--color-white);
|
||||
--node-component-tooltip-border: var(--color-charcoal-200);
|
||||
--node-component-tooltip-border: var(--color-slate-300);
|
||||
--node-component-tooltip-surface: var(--color-charcoal-800);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-800);
|
||||
--node-component-disabled: var(--color-alpha-charcoal-600-30);
|
||||
@@ -369,7 +374,7 @@
|
||||
);
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--text-secondary: var(--color-smoke-700);
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-white);
|
||||
|
||||
--input-surface: rgb(130 130 130 / 0.1);
|
||||
@@ -409,7 +414,7 @@
|
||||
--component-node-widget-background-disabled: var(
|
||||
--color-alpha-charcoal-600-30
|
||||
);
|
||||
--component-node-widget-background-highlighted: var(--color-smoke-800);
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-600);
|
||||
|
||||
|
||||
64
packages/registry-types/src/comfyRegistryTypes.ts
generated
64
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -4062,22 +4062,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/seedance/virtual-library/assets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["seedanceVirtualLibraryCreateAsset"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/seedance/complete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -14506,16 +14490,6 @@ export interface components {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
SeedanceVirtualLibraryCreateAssetRequest: {
|
||||
/** @description Publicly accessible URL of the image asset to upload to the caller's virtual portrait library. */
|
||||
url: string;
|
||||
/** @description Client-supplied content hash used as the per-customer dedup key. Re-submitting the same hash returns the existing asset id without re-uploading to BytePlus. */
|
||||
hash: string;
|
||||
};
|
||||
SeedanceVirtualLibraryCreateAssetResponse: {
|
||||
/** @description BytePlus-issued asset id. Clients poll seedanceGetAsset with this until status == Active. */
|
||||
asset_id: string;
|
||||
};
|
||||
WanVideoGenerationRequest: {
|
||||
/**
|
||||
* @description The ID of the model to call
|
||||
@@ -30364,7 +30338,10 @@ export interface operations {
|
||||
};
|
||||
seedanceGetAsset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description BytePlus project name. Defaults to "default" if omitted. Must match the ProjectName used at create time. */
|
||||
project_name?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description BytePlus-issued asset id returned by seedanceCreateAsset */
|
||||
@@ -30394,39 +30371,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceVirtualLibraryCreateAsset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Asset creation accepted (asynchronous — poll seedanceGetAsset) */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Error 4xx/5xx */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceVisualValidateCallback: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -8,10 +8,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
|
||||
workers: 1,
|
||||
use: {
|
||||
trace: 'on',
|
||||
video: 'on',
|
||||
launchOptions: {
|
||||
slowMo: Number(process.env.SLOW_MO) || 0
|
||||
}
|
||||
video: 'on'
|
||||
}
|
||||
}
|
||||
: {
|
||||
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -162,9 +162,6 @@ catalogs:
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -363,9 +360,6 @@ catalogs:
|
||||
unplugin-vue-components:
|
||||
specifier: ^30.0.0
|
||||
version: 30.0.0
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
vite-plugin-dts:
|
||||
specifier: ^4.5.4
|
||||
version: 4.5.4
|
||||
@@ -503,9 +497,6 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2
|
||||
'@vee-validate/zod':
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -596,9 +587,6 @@ importers:
|
||||
typegpu:
|
||||
specifier: 'catalog:'
|
||||
version: 0.8.2
|
||||
vee-validate:
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -961,9 +949,6 @@ importers:
|
||||
lenis:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
|
||||
posthog-js:
|
||||
specifier: 'catalog:'
|
||||
version: 1.358.1
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4736,11 +4721,6 @@ packages:
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/analytics@2.0.1':
|
||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
||||
peerDependencies:
|
||||
@@ -9613,11 +9593,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -14063,14 +14038,6 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
@@ -14189,7 +14156,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20084,12 +20051,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -55,7 +55,6 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
@@ -122,7 +121,6 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
|
||||
21
src/App.vue
21
src/App.vue
@@ -15,7 +15,7 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { isStaleChunkError, parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -92,17 +92,14 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
}
|
||||
// Disabled: Third-party custom node extensions frequently trigger this toast
|
||||
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
|
||||
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
|
||||
// the generic error message alarms users and offers no actionable guidance.
|
||||
// The console.error above still logs the details for developers to debug.
|
||||
// useToastStore().add({
|
||||
// severity: 'error',
|
||||
// summary: t('g.preloadErrorTitle'),
|
||||
// detail: t('g.preloadError'),
|
||||
// life: 10000
|
||||
// })
|
||||
if (isStaleChunkError(info)) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1"
|
||||
data-testid="bounding-box"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import EditableText from './EditableText.vue'
|
||||
@@ -15,6 +17,10 @@ describe('EditableText', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(EditableText, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputText }
|
||||
},
|
||||
props: {
|
||||
...props,
|
||||
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<template>
|
||||
<div class="editable-text inline">
|
||||
<div class="editable-text">
|
||||
<component :is="labelType" v-if="!isEditing" :class="labelClass">
|
||||
{{ modelValue }}
|
||||
</component>
|
||||
<Input
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
v-model:model-value="inputValue"
|
||||
v-focus
|
||||
type="text"
|
||||
class="h-full rounded-none p-0 focus-visible:ring-0"
|
||||
v-bind="inputAttrs"
|
||||
@blur="finishEditing"
|
||||
@keydown.enter.capture.stop="inputRef?.blur()"
|
||||
size="small"
|
||||
fluid
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: finishEditing,
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keydown.enter.capture.stop="blurInputElement"
|
||||
@keydown.escape.capture.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@@ -23,10 +29,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
@@ -43,23 +48,30 @@ const {
|
||||
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof Input>>()
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const isCanceling = ref(false)
|
||||
|
||||
function finishEditing() {
|
||||
const blurInputElement = () => {
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
inputRef.value?.$el.blur()
|
||||
}
|
||||
const finishEditing = () => {
|
||||
// Don't save if we're canceling
|
||||
if (!isCanceling.value) {
|
||||
emit('edit', inputValue.value)
|
||||
}
|
||||
isCanceling.value = false
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
const cancelEditing = () => {
|
||||
// Set canceling flag to prevent blur from saving
|
||||
isCanceling.value = true
|
||||
// Reset to original value
|
||||
inputValue.value = modelValue
|
||||
// Emit cancel event
|
||||
emit('cancel')
|
||||
inputRef.value?.blur()
|
||||
// Blur the input to exit edit mode
|
||||
blurInputElement()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isEditing,
|
||||
async (newVal) => {
|
||||
@@ -70,14 +82,27 @@ watch(
|
||||
const fileName = inputValue.value.includes('.')
|
||||
? inputValue.value.split('.').slice(0, -1).join('.')
|
||||
: inputValue.value
|
||||
inputRef.value.setSelectionRange(0, fileName.length)
|
||||
const start = 0
|
||||
const end = fileName.length
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
const inputElement = inputRef.value.$el
|
||||
inputElement.setSelectionRange?.(start, end)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const vFocus = {
|
||||
mounted: (el: HTMLElement) => el.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editable-text {
|
||||
display: inline;
|
||||
}
|
||||
.editable-text input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import PromptDialogContent from './PromptDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof PromptDialogContent>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
describe('PromptDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function renderComponent(props: Partial<Props> = {}) {
|
||||
const user = userEvent.setup()
|
||||
render(PromptDialogContent, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
message: 'Enter a name',
|
||||
defaultValue: '',
|
||||
onConfirm: vi.fn(),
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('pre-fills the input with defaultValue', () => {
|
||||
renderComponent({ defaultValue: 'my workflow' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('my workflow')
|
||||
})
|
||||
|
||||
it('calls onConfirm and closes dialog when Confirm is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'renamed')
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('renamed')
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onConfirm when Enter is pressed inside the input', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
|
||||
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'via enter')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('via enter')
|
||||
})
|
||||
|
||||
it('closes dialog when Confirm button is clicked', async () => {
|
||||
const { user } = renderComponent({ defaultValue: '' })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }))
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('selects all text when the input is focused', async () => {
|
||||
renderComponent({ defaultValue: 'pre-filled text', onConfirm: vi.fn() })
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
const spy = vi.spyOn(input, 'setSelectionRange')
|
||||
await fireEvent.focus(input)
|
||||
expect(spy).toHaveBeenCalledWith(0, 'pre-filled text'.length)
|
||||
})
|
||||
})
|
||||
@@ -1,43 +1,49 @@
|
||||
<template>
|
||||
<div class="prompt-dialog-content flex flex-col gap-2 pt-8">
|
||||
<label class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{{ message }}
|
||||
<Input
|
||||
<FloatLabel>
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
:placeholder
|
||||
autofocus
|
||||
@keyup.enter="handleConfirm"
|
||||
@focus="inputRef?.selectAll()"
|
||||
@keyup.enter="onConfirm"
|
||||
@focus="selectAllText"
|
||||
/>
|
||||
</label>
|
||||
<Button @click="handleConfirm">
|
||||
<label>{{ message }}</label>
|
||||
</FloatLabel>
|
||||
<Button @click="onConfirm">
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { message, defaultValue, onConfirm, placeholder } = defineProps<{
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
defaultValue: string
|
||||
onConfirm: (value: string) => void
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const inputValue = ref<string>(defaultValue)
|
||||
const inputValue = ref<string>(props.defaultValue)
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(inputValue.value)
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(inputValue.value)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
const inputRef = ref<InstanceType<typeof Input>>()
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const selectAllText = () => {
|
||||
if (!inputRef.value) return
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
const inputElement = inputRef.value.$el
|
||||
inputElement.setSelectionRange(0, inputElement.value.length)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import SignUpForm from './SignUpForm.vue'
|
||||
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
const mockLoadingRef = ref(false)
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
get loading() {
|
||||
return mockLoadingRef.value
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(SignUpForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Password manager autofill attributes', () => {
|
||||
it('renders email input with attributes Chrome needs to recognize the field', () => {
|
||||
renderComponent()
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.signup.emailPlaceholder
|
||||
)
|
||||
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email')
|
||||
expect(emailInput).toHaveAttribute('name', 'email')
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(emailInput).toHaveAttribute('type', 'email')
|
||||
})
|
||||
|
||||
it('renders password input with new-password autofill attributes', () => {
|
||||
renderComponent()
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.signup.passwordPlaceholder
|
||||
)
|
||||
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password')
|
||||
expect(passwordInput).toHaveAttribute('name', 'password')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
|
||||
})
|
||||
|
||||
it('renders confirm-password input with distinct name and new-password autocomplete', () => {
|
||||
renderComponent()
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.login.confirmPasswordPlaceholder
|
||||
)
|
||||
expect(confirmPasswordInput).toHaveAttribute(
|
||||
'id',
|
||||
'comfy-org-sign-up-confirm-password'
|
||||
)
|
||||
expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword')
|
||||
expect(confirmPasswordInput).toHaveAttribute(
|
||||
'autocomplete',
|
||||
'new-password'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,9 @@
|
||||
</label>
|
||||
<InputText
|
||||
pt:root:id="comfy-org-sign-up-email"
|
||||
pt:root:name="email"
|
||||
pt:root:autocomplete="email"
|
||||
class="h-10"
|
||||
type="email"
|
||||
type="text"
|
||||
:placeholder="t('auth.signup.emailPlaceholder')"
|
||||
:invalid="$field.invalid"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
|
||||
>
|
||||
<!-- Native drag area for Electron -->
|
||||
<div
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
@@ -184,6 +189,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
@@ -140,11 +140,11 @@ const iconClass = computed(() => {
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'animate-spin text-text-secondary'
|
||||
return 'animate-spin text-slate-100'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'text-danger-200'
|
||||
}
|
||||
return 'text-text-secondary'
|
||||
return 'text-slate-100'
|
||||
})
|
||||
</script>
|
||||
|
||||
136
src/components/queue/job/JobGroupsList.test.ts
Normal file
136
src/components/queue/job/JobGroupsList.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const QueueJobItemStub = defineComponent({
|
||||
name: 'QueueJobItemStub',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined },
|
||||
state: { type: String, required: true },
|
||||
title: { type: String, required: true },
|
||||
rightText: { type: String, default: '' },
|
||||
iconName: { type: String, default: undefined },
|
||||
iconImageUrl: { type: String, default: undefined },
|
||||
showClear: { type: Boolean, default: undefined },
|
||||
showMenu: { type: Boolean, default: undefined },
|
||||
progressTotalPercent: { type: Number, default: undefined },
|
||||
progressCurrentPercent: { type: Number, default: undefined },
|
||||
runningNodeName: { type: String, default: undefined },
|
||||
activeDetailsId: { type: String, default: null }
|
||||
},
|
||||
template: `
|
||||
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
|
||||
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
|
||||
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
|
||||
const { taskRef, ...rest } = overrides
|
||||
return {
|
||||
id: 'job-id',
|
||||
title: 'Example job',
|
||||
meta: 'Meta text',
|
||||
state: 'running',
|
||||
iconName: 'icon',
|
||||
iconImageUrl: 'https://example.com/icon.png',
|
||||
showClear: true,
|
||||
taskRef: (taskRef ?? {
|
||||
workflow: { id: 'workflow-id' }
|
||||
}) as TaskItemImpl,
|
||||
progressTotalPercent: 60,
|
||||
progressCurrentPercent: 30,
|
||||
runningNodeName: 'Node A',
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveDetailsId(container: Element, jobId: string): string | null {
|
||||
return (
|
||||
container
|
||||
.querySelector(`[data-job-id="${jobId}"]`)
|
||||
?.getAttribute('data-active-details-id') ?? null
|
||||
)
|
||||
}
|
||||
|
||||
const renderComponent = (groups: JobGroup[]) =>
|
||||
render(JobGroupsList, {
|
||||
props: { displayedJobGroups: groups },
|
||||
global: {
|
||||
stubs: {
|
||||
QueueJobItem: QueueJobItemStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobGroupsList hover behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('delays showing and hiding details while hovering over job rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = createJobItem({ id: 'job-d' })
|
||||
const { container } = renderComponent([
|
||||
{ key: 'today', label: 'Today', items: [job] }
|
||||
])
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-d'))
|
||||
vi.advanceTimersByTime(199)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-d'))
|
||||
vi.advanceTimersByTime(149)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
|
||||
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
|
||||
const { container } = renderComponent([
|
||||
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
|
||||
])
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-1'))
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-1'))
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-2'))
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-2'))
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
|
||||
})
|
||||
})
|
||||
82
src/components/queue/job/JobGroupsList.vue
Normal file
82
src/components/queue/job/JobGroupsList.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-[12px] leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<QueueJobItem
|
||||
v-for="ji in group.items"
|
||||
:key="ji.id"
|
||||
:job-id="ji.id"
|
||||
:workflow-id="ji.taskRef?.workflowId"
|
||||
:state="ji.state"
|
||||
:title="ji.title"
|
||||
:right-text="ji.meta"
|
||||
:icon-name="ji.iconName"
|
||||
:icon-image-url="ji.iconImageUrl"
|
||||
:show-clear="ji.showClear"
|
||||
:show-menu="true"
|
||||
:progress-total-percent="ji.progressTotalPercent"
|
||||
:progress-current-percent="ji.progressCurrentPercent"
|
||||
:running-node-name="ji.runningNodeName"
|
||||
:active-details-id="activeDetailsId"
|
||||
@cancel="emitCancelItem(ji)"
|
||||
@delete="emitDeleteItem(ji)"
|
||||
@menu="(ev) => $emit('menu', ji, ev)"
|
||||
@view="$emit('viewItem', ji)"
|
||||
@details-enter="onDetailsEnter"
|
||||
@details-leave="onDetailsLeave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
activeDetails: activeDetailsId,
|
||||
clearHoverTimers,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
} = useJobDetailsHover<string>({
|
||||
getActiveId: (jobId) => jobId,
|
||||
getDisplayedJobGroups: () => displayedJobGroups
|
||||
})
|
||||
|
||||
function emitCancelItem(item: JobListItem) {
|
||||
emit('cancelItem', item)
|
||||
}
|
||||
|
||||
function emitDeleteItem(item: JobListItem) {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
function onDetailsEnter(jobId: string) {
|
||||
if (activeDetailsId.value === jobId) {
|
||||
clearHoverTimers()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleDetailsShow(jobId)
|
||||
}
|
||||
|
||||
function onDetailsLeave(jobId: string) {
|
||||
scheduleDetailsHide(jobId)
|
||||
}
|
||||
</script>
|
||||
65
src/components/queue/job/QueueAssetPreview.vue
Normal file
65
src/components/queue/job/QueueAssetPreview.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
|
||||
<div class="p-3">
|
||||
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
ref="imgRef"
|
||||
:src="imageUrl"
|
||||
:alt="name"
|
||||
class="size-full cursor-pointer object-contain"
|
||||
@click="$emit('image-click')"
|
||||
@load="onImgLoad"
|
||||
/>
|
||||
<div
|
||||
v-if="timeLabel"
|
||||
class="absolute bottom-2 left-2 rounded-sm px-2 py-0.5 text-xs text-text-primary"
|
||||
:style="{
|
||||
background: 'rgba(217, 217, 217, 0.40)',
|
||||
backdropFilter: 'blur(2px)'
|
||||
}"
|
||||
>
|
||||
{{ timeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<div
|
||||
class="truncate text-sm/normal font-semibold text-text-primary"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="width && height"
|
||||
class="mt-1 text-xs/normal text-text-secondary"
|
||||
>
|
||||
{{ width }}x{{ height }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
defineProps<{
|
||||
imageUrl: string
|
||||
name: string
|
||||
timeLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits(['image-click'])
|
||||
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
|
||||
const onImgLoad = () => {
|
||||
const el = imgRef.value
|
||||
if (!el) return
|
||||
width.value = el.naturalWidth || null
|
||||
height.value = el.naturalHeight || null
|
||||
}
|
||||
</script>
|
||||
133
src/components/queue/job/QueueJobItem.stories.ts
Normal file
133
src/components/queue/job/QueueJobItem.stories.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import QueueJobItem from './QueueJobItem.vue'
|
||||
|
||||
const meta: Meta<typeof QueueJobItem> = {
|
||||
title: 'Queue/QueueJobItem',
|
||||
component: QueueJobItem,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
},
|
||||
argTypes: {
|
||||
onCancel: { action: 'cancel' },
|
||||
onDelete: { action: 'delete' },
|
||||
onMenu: { action: 'menu' },
|
||||
onView: { action: 'view' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumb = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
|
||||
|
||||
export const PendingRecentlyAdded: Story = {
|
||||
args: {
|
||||
jobId: 'job-pending-added-1',
|
||||
state: 'pending',
|
||||
title: 'Job added to queue',
|
||||
rightText: '12:30 PM',
|
||||
iconName: 'icon-[lucide--check]'
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
jobId: 'job-pending-1',
|
||||
state: 'pending',
|
||||
title: 'Pending job',
|
||||
rightText: '12:31 PM'
|
||||
}
|
||||
}
|
||||
|
||||
export const Initialization: Story = {
|
||||
args: {
|
||||
jobId: 'job-init-1',
|
||||
state: 'initialization',
|
||||
title: 'Initializing...'
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningTotalOnly: Story = {
|
||||
args: {
|
||||
jobId: 'job-running-1',
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 42
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningWithCurrent: Story = {
|
||||
args: {
|
||||
jobId: 'job-running-2',
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 66,
|
||||
progressCurrentPercent: 10
|
||||
}
|
||||
}
|
||||
|
||||
export const CompletedWithPreview: Story = {
|
||||
args: {
|
||||
jobId: 'job-completed-1',
|
||||
state: 'completed',
|
||||
title: 'Prompt #1234',
|
||||
rightText: '12.79s',
|
||||
iconImageUrl: thumb('4dabf7')
|
||||
}
|
||||
}
|
||||
|
||||
export const CompletedNoPreview: Story = {
|
||||
args: {
|
||||
jobId: 'job-completed-2',
|
||||
state: 'completed',
|
||||
title: 'Prompt #5678',
|
||||
rightText: '8.12s'
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
jobId: 'job-failed-1',
|
||||
state: 'failed',
|
||||
title: 'Failed job',
|
||||
rightText: 'Failed'
|
||||
}
|
||||
}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: (args) => ({
|
||||
components: { QueueJobItem },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2 w-[420px]">
|
||||
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
|
||||
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
|
||||
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
|
||||
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
|
||||
<QueueJobItem
|
||||
job-id="job-running-2"
|
||||
state="running"
|
||||
title="Generating image"
|
||||
:progress-total-percent="66"
|
||||
:progress-current-percent="10"
|
||||
running-node-name="KSampler"
|
||||
v-bind="args"
|
||||
/>
|
||||
<QueueJobItem
|
||||
job-id="job-completed-1"
|
||||
state="completed"
|
||||
title="Prompt #1234"
|
||||
right-text="12.79s"
|
||||
icon-image-url="${thumb('4dabf7')}"
|
||||
v-bind="args"
|
||||
/>
|
||||
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
|
||||
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
362
src/components/queue/job/QueueJobItem.vue
Normal file
362
src/components/queue/job/QueueJobItem.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="relative"
|
||||
@mouseenter="onRowEnter"
|
||||
@mouseleave="onRowLeave"
|
||||
@contextmenu.stop.prevent="onContextMenu"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="!isPreviewVisible && showDetails && popoverPosition"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isPreviewVisible && canShowPreview && popoverPosition"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPreviewEnter"
|
||||
@mouseleave="onPreviewLeave"
|
||||
>
|
||||
<QueueAssetPreview
|
||||
:image-url="iconImageUrl!"
|
||||
:name="title"
|
||||
:time-label="rightText || undefined"
|
||||
@image-click="emit('view')"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div
|
||||
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
state === 'running' &&
|
||||
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
|
||||
"
|
||||
:class="progressBarContainerClass"
|
||||
>
|
||||
<div
|
||||
v-if="hasProgressPercent(progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(progressTotalPercent)"
|
||||
/>
|
||||
<div
|
||||
v-if="hasProgressPercent(progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(progressCurrentPercent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-1">
|
||||
<div class="relative inline-flex items-center justify-center">
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 size-10 -translate-1/2"
|
||||
@mouseenter.stop="onIconEnter"
|
||||
@mouseleave.stop="onIconLeave"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex size-6 items-center justify-center overflow-hidden rounded-[6px]"
|
||||
>
|
||||
<img
|
||||
v-if="iconImageUrl"
|
||||
:src="iconImageUrl"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="title">
|
||||
<slot name="primary">{{ title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: Refactor action buttons to use a declarative config system.
|
||||
|
||||
Instead of hardcoding button visibility logic in the template, define an array of
|
||||
action button configs with properties like:
|
||||
- icon, label, action, tooltip
|
||||
- visibleStates: JobState[] (which job states show this button)
|
||||
- alwaysVisible: boolean (show without hover)
|
||||
- destructive: boolean (use destructive styling)
|
||||
|
||||
Then render buttons in two groups:
|
||||
1. Always-visible buttons (outside Transition)
|
||||
2. Hover-only buttons (inside Transition)
|
||||
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-1 flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
|
||||
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-0.5"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-0.5"
|
||||
>
|
||||
<div
|
||||
v-if="isHovered"
|
||||
key="actions"
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<Button
|
||||
v-if="state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="onDeleteClick"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
state !== 'completed' &&
|
||||
state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emit('view')"
|
||||
>{{ t('menuLabels.View') }}</Button
|
||||
>
|
||||
<Button
|
||||
v-if="showMenu !== undefined ? showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<Button
|
||||
v-if="state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
jobId,
|
||||
workflowId,
|
||||
state,
|
||||
title,
|
||||
rightText = '',
|
||||
iconName,
|
||||
iconImageUrl,
|
||||
showClear,
|
||||
showMenu,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent,
|
||||
activeDetailsId = null
|
||||
} = defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'menu', event: MouseEvent): void
|
||||
(e: 'view'): void
|
||||
(e: 'details-enter', jobId: string): void
|
||||
(e: 'details-leave', jobId: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
hasProgressPercent,
|
||||
hasAnyProgressPercent,
|
||||
progressPercentStyle
|
||||
} = useProgressBarBackground()
|
||||
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => activeDetailsId === jobId)
|
||||
|
||||
const onRowEnter = () => {
|
||||
if (!isPreviewVisible.value) emit('details-enter', jobId)
|
||||
}
|
||||
const onRowLeave = () => emit('details-leave', jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const previewHideTimer = ref<number | null>(null)
|
||||
const previewShowTimer = ref<number | null>(null)
|
||||
const clearPreviewHideTimer = () => {
|
||||
if (previewHideTimer.value !== null) {
|
||||
clearTimeout(previewHideTimer.value)
|
||||
previewHideTimer.value = null
|
||||
}
|
||||
}
|
||||
const clearPreviewShowTimer = () => {
|
||||
if (previewShowTimer.value !== null) {
|
||||
clearTimeout(previewShowTimer.value)
|
||||
previewShowTimer.value = null
|
||||
}
|
||||
}
|
||||
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
|
||||
const scheduleShowPreview = () => {
|
||||
if (!canShowPreview.value) return
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
previewShowTimer.value = window.setTimeout(() => {
|
||||
isPreviewVisible.value = true
|
||||
previewShowTimer.value = null
|
||||
}, 200)
|
||||
}
|
||||
const scheduleHidePreview = () => {
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
previewHideTimer.value = window.setTimeout(() => {
|
||||
isPreviewVisible.value = false
|
||||
previewHideTimer.value = null
|
||||
}, 150)
|
||||
}
|
||||
const onIconEnter = () => scheduleShowPreview()
|
||||
const onIconLeave = () => scheduleHidePreview()
|
||||
const onPreviewEnter = () => scheduleShowPreview()
|
||||
const onPreviewLeave = () => scheduleHidePreview()
|
||||
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rowRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
}
|
||||
|
||||
const isAnyPopoverVisible = computed(
|
||||
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
isAnyPopoverVisible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
nextTick(updatePopoverPosition)
|
||||
} else {
|
||||
popoverPosition.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (iconName) return iconName
|
||||
return iconForJobState(state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (showClear !== undefined) return showClear
|
||||
return state !== 'completed'
|
||||
})
|
||||
|
||||
const emitDetailsLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const onCancelClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const onDeleteClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const shouldShowMenu = showMenu !== undefined ? showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
}
|
||||
</script>
|
||||
@@ -5,11 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -17,14 +15,10 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1280, height: 800 }
|
||||
const MOBILE_VIEWPORT = { width: 360, height: 800 }
|
||||
|
||||
describe('NodeSearchContent', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
@@ -553,7 +547,7 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
describe('filter integration', () => {
|
||||
it('renders one chip per active filter with the filter value', () => {
|
||||
it('should display active filters in the input area', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -562,20 +556,16 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
|
||||
renderComponent({
|
||||
filters: [
|
||||
{ filterDef: inputFilter, value: 'IMAGE' },
|
||||
{ filterDef: inputFilter, value: 'LATENT' }
|
||||
{
|
||||
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
|
||||
value: 'IMAGE'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const chipTexts = screen
|
||||
.getAllByTestId('filter-chip')
|
||||
.map((c) => c.textContent ?? '')
|
||||
expect(chipTexts).toHaveLength(2)
|
||||
expect(chipTexts.some((t) => t.includes('IMAGE'))).toBe(true)
|
||||
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
|
||||
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -669,95 +659,6 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar toggle', () => {
|
||||
it('should hide and show the category sidebar when the toggle is clicked', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const sidebar = await screen.findByTestId('category-sampling')
|
||||
expect(sidebar).toBeVisible()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByTestId('category-sampling')).not.toBeVisible()
|
||||
})
|
||||
|
||||
await user.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('category-sampling')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the sidebar when the search input gains focus on mobile', async () => {
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve user state across mobile/desktop resizes', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', value))
|
||||
|
||||
await expectExpanded('true')
|
||||
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
|
||||
await user.click(toggle)
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
await expectExpanded('true')
|
||||
|
||||
await user.click(toggle)
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rootFilter + category + search combination', () => {
|
||||
it('should intersect rootFilter, selected category, and search query', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
|
||||
@@ -13,13 +13,11 @@
|
||||
@navigate-down="navigateResults(1)"
|
||||
@navigate-up="navigateResults(-1)"
|
||||
@select-current="selectCurrentResult"
|
||||
@focusin="onSearchFocus"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
v-model:is-sidebar-open="isSidebarOpen"
|
||||
class="flex-1"
|
||||
:filters="filters"
|
||||
:active-category="rootFilter"
|
||||
@@ -36,13 +34,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar -->
|
||||
<NodeSearchCategorySidebar
|
||||
v-show="isSidebarOpen"
|
||||
id="node-search-category-sidebar"
|
||||
v-model:selected-category="sidebarCategory"
|
||||
:aria-label="isMobile ? t('g.categories') : undefined"
|
||||
class="w-52 shrink-0 max-md:absolute max-md:inset-y-0 max-md:left-0 max-md:z-20 max-md:bg-base-background max-md:shadow-interface"
|
||||
class="w-52 shrink-0"
|
||||
:hide-chevrons="!anyTreeCategoryHasChildren"
|
||||
:hide-presets="rootFilter !== null"
|
||||
:node-defs="rootFilteredNodeDefs"
|
||||
@@ -51,14 +47,6 @@
|
||||
@auto-expand="selectedCategory = $event"
|
||||
/>
|
||||
|
||||
<!-- Mobile overlay backdrop to close sidebar on outside click -->
|
||||
<div
|
||||
v-if="isMobile && isSidebarOpen"
|
||||
data-testid="sidebar-backdrop"
|
||||
class="absolute inset-0 z-10 md:hidden"
|
||||
@click="isSidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Results list -->
|
||||
<div
|
||||
id="results-list"
|
||||
@@ -90,8 +78,8 @@
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="rootFilter !== RootCategory.Essentials"
|
||||
:hide-bookmark-icon="selectedCategory === RootCategory.Favorites"
|
||||
:show-source-badge="rootFilter !== 'essentials'"
|
||||
:hide-bookmark-icon="selectedCategory === 'favorites'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -108,7 +96,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { FocusScope } from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -119,8 +106,6 @@ import NodeSearchCategorySidebar, {
|
||||
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -136,9 +121,9 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
{
|
||||
[RootCategory.Essentials]: isEssentialNode,
|
||||
[RootCategory.Comfy]: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
essentials: isEssentialNode,
|
||||
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
custom: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
@@ -182,33 +167,22 @@ const searchQuery = ref('')
|
||||
const selectedCategory = ref(DEFAULT_CATEGORY)
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
const isSidebarOpen = ref(!isMobile.value)
|
||||
watch(isMobile, (mobile) => {
|
||||
// On transitioning to mobile state, close the sidebar
|
||||
if (mobile) isSidebarOpen.value = false
|
||||
})
|
||||
|
||||
function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<string | null>(null)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
case RootCategory.Favorites:
|
||||
case 'favorites':
|
||||
return t('g.bookmarked')
|
||||
case RootCategory.Blueprint:
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return t('g.blueprints')
|
||||
case RootCategory.PartnerNodes:
|
||||
case 'partner-nodes':
|
||||
return t('g.partner')
|
||||
case RootCategory.Essentials:
|
||||
case 'essentials':
|
||||
return t('g.essentials')
|
||||
case RootCategory.Comfy:
|
||||
case 'comfy':
|
||||
return t('g.comfy')
|
||||
case RootCategory.Custom:
|
||||
case 'custom':
|
||||
return t('g.extensions')
|
||||
default:
|
||||
return undefined
|
||||
@@ -221,11 +195,11 @@ const rootFilteredNodeDefs = computed(() => {
|
||||
const sourceFilter = sourceCategoryFilters[rootFilter.value]
|
||||
if (sourceFilter) return allNodes.filter(sourceFilter)
|
||||
switch (rootFilter.value) {
|
||||
case RootCategory.Favorites:
|
||||
case 'favorites':
|
||||
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
case RootCategory.Blueprint:
|
||||
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
|
||||
case RootCategory.PartnerNodes:
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
|
||||
case 'partner-nodes':
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
default:
|
||||
return allNodes
|
||||
@@ -252,7 +226,7 @@ function onClearFilterGroup(filterId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectCategory(category: RootCategoryId) {
|
||||
function onSelectCategory(category: string) {
|
||||
if (rootFilter.value === category) {
|
||||
rootFilter.value = null
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -9,16 +9,23 @@ import {
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe(NodeSearchFilterBar, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setupTestPinia()
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -31,13 +38,8 @@ describe(NodeSearchFilterBar, () => {
|
||||
async function createRender(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onSelectCategory = vi.fn()
|
||||
const onUpdateIsSidebarOpen = vi.fn()
|
||||
render(NodeSearchFilterBar, {
|
||||
props: {
|
||||
onSelectCategory,
|
||||
'onUpdate:isSidebarOpen': onUpdateIsSidebarOpen,
|
||||
...props
|
||||
},
|
||||
props: { onSelectCategory, ...props },
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
@@ -49,38 +51,51 @@ describe(NodeSearchFilterBar, () => {
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return { user, onSelectCategory, onUpdateIsSidebarOpen }
|
||||
return { user, onSelectCategory }
|
||||
}
|
||||
|
||||
const buttonTexts = () =>
|
||||
screen.getAllByRole('button').map((b) => b.textContent?.trim())
|
||||
it('should render Extensions button and Input/Output popover triggers', async () => {
|
||||
await createRender({ hasCustomNodes: true })
|
||||
|
||||
it.each([
|
||||
{ prop: 'hasFavorites', label: 'Bookmarked' },
|
||||
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
|
||||
{ prop: 'hasEssentialNodes', label: 'Essentials' },
|
||||
{ prop: 'hasPartnerNodes', label: 'Partner' },
|
||||
{ prop: 'hasCustomNodes', label: 'Extensions' }
|
||||
] as const)(
|
||||
'shows the $label button only when $prop is true',
|
||||
async ({ prop, label }) => {
|
||||
await createRender()
|
||||
expect(buttonTexts()).not.toContain(label)
|
||||
|
||||
cleanup()
|
||||
await createRender({ [prop]: true })
|
||||
expect(buttonTexts()).toContain(label)
|
||||
}
|
||||
)
|
||||
|
||||
it('always renders the Comfy button and Input/Output type filter triggers', async () => {
|
||||
await createRender()
|
||||
const texts = buttonTexts()
|
||||
expect(texts).toContain('Comfy')
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const texts = buttons.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Extensions')
|
||||
expect(texts).toContain('Input')
|
||||
expect(texts).toContain('Output')
|
||||
})
|
||||
|
||||
it('should always render Comfy button', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Comfy')
|
||||
})
|
||||
|
||||
it('should render conditional category buttons when matching nodes exist', async () => {
|
||||
await createRender({
|
||||
hasFavorites: true,
|
||||
hasEssentialNodes: true,
|
||||
hasBlueprintNodes: true,
|
||||
hasPartnerNodes: true
|
||||
})
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Bookmarked')
|
||||
expect(texts).toContain('Blueprints')
|
||||
expect(texts).toContain('Partner')
|
||||
expect(texts).toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should not render Extensions button when no custom nodes exist', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should emit selectCategory when category button is clicked', async () => {
|
||||
const { user, onSelectCategory } = await createRender({
|
||||
hasCustomNodes: true
|
||||
@@ -99,24 +114,4 @@ describe(NodeSearchFilterBar, () => {
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose aria-expanded=false and emit update:isSidebarOpen=true when toggled from collapsed', async () => {
|
||||
const { user, onUpdateIsSidebarOpen } = await createRender({
|
||||
isSidebarOpen: false
|
||||
})
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(onUpdateIsSidebarOpen).toHaveBeenCalledExactlyOnceWith(true)
|
||||
})
|
||||
|
||||
it('should expose aria-expanded=true when isSidebarOpen prop is true', async () => {
|
||||
await createRender({ isSidebarOpen: true })
|
||||
expect(screen.getByTestId('toggle-category-sidebar')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,67 +1,48 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-2.5 pl-3">
|
||||
<div class="flex items-center gap-2.5 px-3">
|
||||
<!-- Category filter buttons -->
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
data-testid="toggle-category-sidebar"
|
||||
aria-controls="node-search-category-sidebar"
|
||||
:aria-expanded="isSidebarOpen"
|
||||
:aria-label="isSidebarOpen ? t('g.hideLeftPanel') : t('g.showLeftPanel')"
|
||||
:class="chipClass(isSidebarOpen)"
|
||||
@click="isSidebarOpen = !isSidebarOpen"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
>
|
||||
<i class="icon-[lucide--panel-left] size-4" />
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<div
|
||||
data-testid="filter-chips-scroll"
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto pr-3"
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<!-- Category filter buttons -->
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
>
|
||||
{{ btn.label }}
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
>
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,7 +63,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
@@ -106,13 +86,11 @@ const {
|
||||
hasCustomNodes?: boolean
|
||||
}>()
|
||||
|
||||
const isSidebarOpen = defineModel<boolean>('isSidebarOpen', { default: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
|
||||
clearFilterGroup: [filterId: string]
|
||||
focusSearch: []
|
||||
selectCategory: [category: RootCategoryId]
|
||||
selectCategory: [category: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -121,20 +99,20 @@ const nodeDefStore = useNodeDefStore()
|
||||
const MAX_VISIBLE_DOTS = 4
|
||||
|
||||
const categoryButtons = computed(() => {
|
||||
const buttons: { id: RootCategoryId; label: string }[] = []
|
||||
const buttons: { id: string; label: string }[] = []
|
||||
if (hasFavorites) {
|
||||
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
|
||||
}
|
||||
if (hasBlueprintNodes) {
|
||||
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
|
||||
}
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
|
||||
}
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
|
||||
}
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
|
||||
}
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasCustomNodes) {
|
||||
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
|
||||
}
|
||||
@@ -168,7 +146,7 @@ const typeFilters = computed(() => [
|
||||
|
||||
function chipClass(isActive: boolean, hasSelections = false) {
|
||||
return cn(
|
||||
'flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
isActive
|
||||
? 'border-base-foreground bg-base-foreground text-base-background'
|
||||
: hasSelections
|
||||
|
||||
@@ -57,19 +57,6 @@ describe('NodeSearchListItem', () => {
|
||||
})
|
||||
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides id name for subgraph blueprints even when ShowIdName is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'SubgraphBlueprint.e21be61fc452df75e1324e3cc97c41fb0c01a08a5dad4dcd3a2ac118d8907025',
|
||||
display_name: 'My Blueprint',
|
||||
python_module: 'blueprint'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByTestId('node-id-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDescription mode', () => {
|
||||
|
||||
@@ -155,10 +155,8 @@ const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
)
|
||||
const showIdName = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
|
||||
const showIdName = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
|
||||
)
|
||||
const showNodeFrequency = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { DetachedWindowAPI } from 'happy-dom'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -36,12 +35,3 @@ export const testI18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
export function setViewport(viewport: { width: number; height: number }) {
|
||||
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
|
||||
.happyDOM
|
||||
if (!happyDOM) {
|
||||
throw new Error('window.happyDOM is unavailable to set viewport')
|
||||
}
|
||||
happyDOM.setViewport(viewport)
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ const {
|
||||
} = useAssetSelection()
|
||||
|
||||
const {
|
||||
downloadAssets,
|
||||
downloadMultipleAssets,
|
||||
deleteAssets,
|
||||
addMultipleToWorkflow,
|
||||
openMultipleWorkflows,
|
||||
@@ -533,7 +533,7 @@ function handleContextMenuHide() {
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadAssets(assets)
|
||||
downloadMultipleAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
@@ -559,7 +559,7 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
downloadAssets(selectedAssets.value)
|
||||
downloadMultipleAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ const dotClasses = computed(() => {
|
||||
return 'bg-gold-600'
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-text-secondary'
|
||||
return 'bg-slate-100'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,12 +14,7 @@ const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
select: () => inputRef.value?.select(),
|
||||
blur: () => inputRef.value?.blur(),
|
||||
setSelectionRange: (start: number, end: number) =>
|
||||
inputRef.value?.setSelectionRange(start, end),
|
||||
selectAll: () =>
|
||||
inputRef.value?.setSelectionRange(0, inputRef.value.value.length)
|
||||
select: () => inputRef.value?.select()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchAutocomplete from './SearchAutocomplete.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
|
||||
})
|
||||
|
||||
describe('SearchAutocomplete', () => {
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
return render(SearchAutocomplete, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: { template: '<div><slot /></div>' },
|
||||
ComboboxAnchor: { template: '<div><slot /></div>' },
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue']
|
||||
},
|
||||
ComboboxPortal: { template: '<div><slot /></div>' },
|
||||
ComboboxContent: { template: '<div><slot /></div>' },
|
||||
ComboboxItem: {
|
||||
template:
|
||||
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
|
||||
emits: ['select']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: { modelValue: '', ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('suggestions dropdown', () => {
|
||||
it('does not render items when suggestions list is empty', () => {
|
||||
renderComponent({ suggestions: [] })
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a button for each suggestion', () => {
|
||||
renderComponent({ suggestions: ['foo', 'bar'] })
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits select with the suggestion when an item is clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onSelect).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
|
||||
it('updates modelValue to the suggestion label on selection', async () => {
|
||||
const onUpdateModelValue = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({
|
||||
suggestions: ['foo', 'bar'],
|
||||
'onUpdate:modelValue': onUpdateModelValue
|
||||
})
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with optionLabel', () => {
|
||||
it('displays the optionLabel property as the suggestion text', () => {
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query' })
|
||||
expect(screen.getByText('my-extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits the full item object on selection when optionLabel is set', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query', onSelect })
|
||||
await user.click(screen.getByText('my-extension'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -65,36 +65,34 @@
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
@@ -107,7 +105,6 @@ import {
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
const { class: className, ...restAttrs } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
|
||||
const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => textareaEl.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-bind="restAttrs"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile && !showLeftPanel"
|
||||
size="lg"
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
|
||||
useCoordinateTransform: () => ({
|
||||
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { registerExtension: vi.fn() }
|
||||
}))
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useBrushAdjustment } from './useBrushAdjustment'
|
||||
|
||||
function makePointerEvent(offsetX: number, offsetY: number): PointerEvent {
|
||||
return {
|
||||
offsetX,
|
||||
offsetY,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('startBrushAdjustment', () => {
|
||||
it('sets brushPreviewGradientVisible to true', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushPreviewGradientVisible = false
|
||||
const { startBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
expect(store.brushPreviewGradientVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBrushAdjustment', () => {
|
||||
it('does nothing when startBrushAdjustment has not been called', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const hardnessBefore = store.brushSettings.hardness
|
||||
const { handleBrushAdjustment } = useBrushAdjustment()
|
||||
await handleBrushAdjustment(makePointerEvent(200, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBe(hardnessBefore)
|
||||
})
|
||||
|
||||
it('does not change size when deltaX is within the dead zone', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(103, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
})
|
||||
|
||||
it('increases size when dragging right past the dead zone', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
|
||||
})
|
||||
|
||||
it('does not compound size when pointer stays at the same position', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
const sizeAfterFirstMove = store.brushSettings.size
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeAfterFirstMove)
|
||||
})
|
||||
|
||||
it('continues increasing size beyond 100px drag (no delta saturation)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(100, 0))
|
||||
const sizeAt100px = store.brushSettings.size
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(300, 0))
|
||||
const sizeAt300px = store.brushSettings.size
|
||||
|
||||
expect(sizeAt300px).toBeGreaterThan(sizeAt100px)
|
||||
})
|
||||
|
||||
it('clamps size to minimum 1 when dragging far left', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.size = 2
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(500, 100))
|
||||
await handleBrushAdjustment(makePointerEvent(0, 100))
|
||||
expect(store.brushSettings.size).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps hardness to maximum 1 when dragging far up', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.9
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
brushAdjustmentSpeed: 10
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(100, 500))
|
||||
await handleBrushAdjustment(makePointerEvent(100, 0))
|
||||
expect(store.brushSettings.hardness).toBe(1)
|
||||
})
|
||||
|
||||
it('suppresses hardness change when X delta dominates (useDominantAxis=true)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.5
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
useDominantAxis: true
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(100, 10))
|
||||
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBe(0.5)
|
||||
})
|
||||
|
||||
it('suppresses size change when Y delta dominates (useDominantAxis=true)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.5
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
useDominantAxis: true
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const hardnessBefore = store.brushSettings.hardness
|
||||
await handleBrushAdjustment(makePointerEvent(10, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBeLessThan(hardnessBefore)
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
|
||||
export function useBrushAdjustment(initialSettings?: {
|
||||
useDominantAxis?: boolean
|
||||
brushAdjustmentSpeed?: number
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const initialBrushSize = ref(0)
|
||||
const initialBrushHardness = ref(0)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
initialBrushSize.value = store.brushSettings.size
|
||||
initialBrushHardness.value = store.brushSettings.hardness
|
||||
}
|
||||
|
||||
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
500,
|
||||
initialBrushSize.value + (finalDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
initialBrushHardness.value -
|
||||
(finalDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
return { startBrushAdjustment, handleBrushAdjustment }
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { tgpu } from 'typegpu'
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { StrokeProcessor } from './StrokeProcessor'
|
||||
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
|
||||
import { useBrushAdjustment } from './useBrushAdjustment'
|
||||
import {
|
||||
resetDirtyRect,
|
||||
updateDirtyRect,
|
||||
@@ -31,8 +30,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const persistence = useBrushPersistence()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } =
|
||||
useBrushAdjustment(initialSettings)
|
||||
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
@@ -66,6 +63,10 @@ export function useBrushDrawing(initialSettings?: {
|
||||
// Stroke processor instance
|
||||
let strokeProcessor: StrokeProcessor | null = null
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
persistence.loadAndApply()
|
||||
|
||||
// Handle external clear events
|
||||
@@ -752,6 +753,78 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the brush adjustment interaction.
|
||||
* @param event - The pointer event.
|
||||
*/
|
||||
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the brush adjustment movement.
|
||||
* @param event - The pointer event.
|
||||
*/
|
||||
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
|
||||
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
500,
|
||||
store.brushSettings.size +
|
||||
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
store.brushSettings.hardness -
|
||||
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back the GPU textures to CPU ImageDatas.
|
||||
* @returns Object containing mask and rgb ImageDatas.
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock } = vi.hoisted(() => ({
|
||||
canvasInteractionsMock: {
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn(),
|
||||
forwardEventToCanvas: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: () => canvasInteractionsMock
|
||||
}))
|
||||
// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.)
|
||||
// that we cannot pull in here, so we stub only the constant we need.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview'
|
||||
}))
|
||||
|
||||
describe('useNodeAnimatedImage', () => {
|
||||
function setup() {
|
||||
vi.clearAllMocks()
|
||||
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
|
||||
const { showAnimatedPreview, removeAnimatedPreview } =
|
||||
useNodeAnimatedImage()
|
||||
showAnimatedPreview(node)
|
||||
const element = node.widgets[0].element
|
||||
document.body.append(element)
|
||||
onTestFinished(() => element.remove())
|
||||
return { node, element, showAnimatedPreview, removeAnimatedPreview }
|
||||
}
|
||||
|
||||
it('forwards non-right-click pointer events and wheel to the canvas while alive', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new WheelEvent('wheel'))
|
||||
element.dispatchEvent(new PointerEvent('pointermove'))
|
||||
element.dispatchEvent(new PointerEvent('pointerup'))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detaches every listener when the preview is removed', () => {
|
||||
const { node, element, removeAnimatedPreview } = setup()
|
||||
removeAnimatedPreview(node)
|
||||
|
||||
element.dispatchEvent(new WheelEvent('wheel'))
|
||||
element.dispatchEvent(new PointerEvent('pointermove'))
|
||||
element.dispatchEvent(new PointerEvent('pointerup'))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
|
||||
@@ -40,20 +39,17 @@ export function useNodeAnimatedImage() {
|
||||
const { handleWheel, handlePointer, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
node.imgs[0].style.pointerEvents = 'none'
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
element.addEventListener('wheel', handleWheel, { signal })
|
||||
element.addEventListener('pointermove', handlePointer, { signal })
|
||||
element.addEventListener('pointerup', handlePointer, { signal })
|
||||
element.addEventListener('wheel', handleWheel)
|
||||
element.addEventListener('pointermove', handlePointer)
|
||||
element.addEventListener('pointerup', handlePointer)
|
||||
element.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => (e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)),
|
||||
{ capture: true, signal }
|
||||
(e) => {
|
||||
return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
widget.onRemove = useChainCallback(widget.onRemove, () => {
|
||||
controller.abort()
|
||||
})
|
||||
widget.serialize = false
|
||||
widget.serializeValue = () => undefined
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
|
||||
canvasInteractionsMock: {
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn()
|
||||
},
|
||||
nodeOutputStoreMock: {
|
||||
getNodeImageUrls: vi.fn<(node: unknown) => string[] | undefined>()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: () => canvasInteractionsMock
|
||||
}))
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => nodeOutputStoreMock
|
||||
}))
|
||||
vi.mock('@/utils/imageUtil', () => ({
|
||||
fitDimensionsToNodeWidth: () => ({ minHeight: 256, minWidth: 256 })
|
||||
}))
|
||||
|
||||
describe('useNodeVideo', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
|
||||
const node = createMockMediaNode({
|
||||
size: [400, 400],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
})
|
||||
|
||||
const createdVideos: HTMLVideoElement[] = []
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tag: string, opts?: ElementCreationOptions) => {
|
||||
const el = realCreateElement(tag, opts)
|
||||
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
|
||||
return el
|
||||
}
|
||||
)
|
||||
|
||||
const { showPreview } = useNodeVideo(node)
|
||||
showPreview()
|
||||
|
||||
// happy-dom does not auto-fire onloadeddata for src assignment, so we
|
||||
// manually trigger it, then drain the resulting promise chain.
|
||||
const video = createdVideos[0]
|
||||
video.onloadeddata?.(new Event('loadeddata'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
onTestFinished(() => {
|
||||
node.widgets[0]?.onRemove?.()
|
||||
})
|
||||
|
||||
return { node, video }
|
||||
}
|
||||
|
||||
it('creates a video-preview widget and forwards canvas events while alive', async () => {
|
||||
const { node, video } = await setup()
|
||||
|
||||
expect(node.widgets[0]?.name).toBe('video-preview')
|
||||
|
||||
video.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('detaches every listener when the widget is removed', async () => {
|
||||
const { node, video } = await setup()
|
||||
|
||||
node.widgets[0]?.onRemove?.()
|
||||
|
||||
video.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -152,6 +151,11 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
|
||||
const video = document.createElement('video')
|
||||
Object.assign(video, VIDEO_DEFAULT_OPTIONS)
|
||||
|
||||
// Add event listeners for canvas interactions
|
||||
video.addEventListener('wheel', handleWheel)
|
||||
video.addEventListener('pointermove', handlePointer)
|
||||
video.addEventListener('pointerdown', handlePointer)
|
||||
|
||||
video.onloadeddata = () => {
|
||||
setMinDimensions(video)
|
||||
resolve(video)
|
||||
@@ -172,16 +176,6 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
|
||||
minHeight,
|
||||
minWidth
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
container.addEventListener('wheel', handleWheel, { signal })
|
||||
container.addEventListener('pointermove', handlePointer, { signal })
|
||||
container.addEventListener('pointerdown', handlePointer, { signal })
|
||||
|
||||
widget.onRemove = useChainCallback(widget.onRemove, () => {
|
||||
controller.abort()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user