mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
35 Commits
austin/dnd
...
test222222
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20dcc369dc | ||
|
|
e0c1c4e994 | ||
|
|
ac7a825fd3 | ||
|
|
e831daae59 | ||
|
|
96575fcec9 | ||
|
|
e7e1ae25a6 | ||
|
|
4ed00cec08 | ||
|
|
f566abdd6e | ||
|
|
3c5695fd42 | ||
|
|
4fff0c4b49 | ||
|
|
69dca2d600 | ||
|
|
004530b23a | ||
|
|
73d4e24ffa | ||
|
|
09790bd7f3 | ||
|
|
dafb944c3b | ||
|
|
d429d481e8 | ||
|
|
a9aae6af4a | ||
|
|
46ba65e25c | ||
|
|
11432f7d0e | ||
|
|
9384beaec6 | ||
|
|
4a05d89fdb | ||
|
|
ef98ba0e8f | ||
|
|
019c1787a5 | ||
|
|
87fab87d84 | ||
|
|
f2a99adaa3 | ||
|
|
a934056246 | ||
|
|
b8dfbfc0bb | ||
|
|
036c79259b | ||
|
|
17c18b0707 | ||
|
|
ca8407218b | ||
|
|
26ac1eece1 | ||
|
|
810381ab63 | ||
|
|
cc1a737291 | ||
|
|
c74e08e244 | ||
|
|
8f9f452c86 |
156
.claude/skills/reviewing-unit-tests/SKILL.md
Normal file
156
.claude/skills/reviewing-unit-tests/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: reviewing-unit-tests
|
||||
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
|
||||
---
|
||||
|
||||
# Reviewing Unit Tests for ComfyUI_frontend
|
||||
|
||||
## Overview
|
||||
|
||||
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
|
||||
|
||||
## Review Workflow
|
||||
|
||||
1. Identify the test type: component, store, composable, util, or bugfix regression.
|
||||
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
|
||||
3. Open the authoritative doc section before judging structure.
|
||||
4. Scan the red flags below.
|
||||
5. State the verdict first. Name the failure mode. Cite the doc or rule.
|
||||
|
||||
## Source of Truth / Precedence
|
||||
|
||||
When docs and examples conflict, use this order:
|
||||
|
||||
1. Explicit repo rules, lint rules, and note blocks.
|
||||
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
|
||||
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
|
||||
4. Example snippets
|
||||
5. Prior diffs
|
||||
|
||||
Apply these repo-specific clarifications:
|
||||
|
||||
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
|
||||
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
|
||||
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
|
||||
- Motion != fix.
|
||||
|
||||
## 30-Second Red Flags
|
||||
|
||||
| If you see... | Failure mode | Default action |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
|
||||
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
|
||||
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
|
||||
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
|
||||
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
|
||||
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
|
||||
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
|
||||
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
|
||||
|
||||
## Rationalization Table
|
||||
|
||||
| Excuse | Reality |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
|
||||
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
|
||||
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
|
||||
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
|
||||
| "It is only a nit" | Explicit repo-rule violations are never nits. |
|
||||
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
|
||||
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
|
||||
|
||||
## Mocking Rules
|
||||
|
||||
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
|
||||
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
|
||||
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
|
||||
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
|
||||
|
||||
### Alias-by-Renaming
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const mockAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
// After: same indirection, new name
|
||||
function getToastAddMock() {
|
||||
return useToast().add
|
||||
}
|
||||
```
|
||||
|
||||
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
|
||||
|
||||
### `vi.mocked()` Scope
|
||||
|
||||
| Use case | `vi.mocked()` required? |
|
||||
| --------------------------------------------------------------- | ----------------------- |
|
||||
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
|
||||
| `.mock.calls`, `.mock.results` | Yes |
|
||||
| `expect(fn).toHaveBeenCalled()` | No |
|
||||
| `expect(fn).toHaveBeenCalledWith(...)` | No |
|
||||
|
||||
- Flag casts whenever `vi.mocked()` would narrow correctly.
|
||||
- Do not add `vi.mocked()` around assertion-only references just for style.
|
||||
|
||||
### Reset Hygiene
|
||||
|
||||
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
|
||||
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
|
||||
|
||||
### Third-Party Seams
|
||||
|
||||
- Distinguish trivial hooks from behavior-rich APIs.
|
||||
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
|
||||
- That exception does not justify mocking behavior-rich third-party modules.
|
||||
|
||||
### `vue-i18n`
|
||||
|
||||
- Never mock `vue-i18n` in component tests.
|
||||
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
|
||||
|
||||
## Test-Body Rules
|
||||
|
||||
| Smell | Review bar |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Change-detector test | Reject. Default values alone prove nothing. |
|
||||
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
|
||||
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
|
||||
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
|
||||
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
|
||||
|
||||
## Bugfix Regression Proof
|
||||
|
||||
For `fix:` PRs or bugfix diffs:
|
||||
|
||||
1. Identify the production change that fixes the bug.
|
||||
2. Verify the new test fails on pre-fix code, or ask the author to show it.
|
||||
3. If the test passes on broken code, request changes.
|
||||
|
||||
A regression test that never proves red does not pin the bug.
|
||||
|
||||
## Review Output Rules
|
||||
|
||||
- State verdict before procedural questions.
|
||||
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
|
||||
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
|
||||
- Link the authoritative doc section in the review comment.
|
||||
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| When you see... | Read this |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
|
||||
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
|
||||
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
|
||||
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
87
.github/actions/changes-filter/action.yaml
vendored
Normal file
87
.github/actions/changes-filter/action.yaml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Outputs default to 'true' for non-pull_request events (push, merge_group):
|
||||
# granular path filtering is a PR-only optimization. This avoids the silent
|
||||
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
|
||||
# would never run on push.
|
||||
#
|
||||
# Shared dependency files (root package.json, pnpm-lock.yaml,
|
||||
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
|
||||
# output so a lockfile bump correctly invalidates each granular gate. They
|
||||
# are NOT folded into docs-changes.
|
||||
#
|
||||
# Two paths-filter steps are needed because predicate-quantifier=every is
|
||||
# required for the negated globs in `should-run` but breaks multi-pattern
|
||||
# OR filters like `docs:` and `deps:`.
|
||||
#
|
||||
# Requires the caller to have checked out the repository.
|
||||
|
||||
name: 'Detect Path Changes'
|
||||
description: >
|
||||
Computes typed *-changes outputs and a back-compat should-run for
|
||||
path-gated CI jobs.
|
||||
|
||||
outputs:
|
||||
should-run:
|
||||
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
|
||||
app-website-changes:
|
||||
description: 'Shared deps or `apps/website/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
|
||||
app-desktop-changes:
|
||||
description: 'Shared deps or `apps/desktop-ui/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
|
||||
app-frontend-changes:
|
||||
description: 'Shared deps or `src/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
|
||||
packages-changes:
|
||||
description: 'Shared deps or `packages/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
|
||||
storybook-changes:
|
||||
description: 'Shared deps or `.storybook/**` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
|
||||
docs-changes:
|
||||
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
|
||||
dependency-changes:
|
||||
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
|
||||
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Filter typed changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
app_website:
|
||||
- 'apps/website/**'
|
||||
app_desktop:
|
||||
- 'apps/desktop-ui/**'
|
||||
app_frontend:
|
||||
- 'src/**'
|
||||
packages:
|
||||
- 'packages/**'
|
||||
storybook:
|
||||
- '.storybook/**'
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
deps:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
- name: Filter relevant changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: relevant
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
predicate-quantifier: 'every'
|
||||
filters: |
|
||||
relevant:
|
||||
- '**'
|
||||
- '!apps/**'
|
||||
- '!docs/**'
|
||||
- '!.storybook/**'
|
||||
- '!**/*.md'
|
||||
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -12,17 +12,30 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
scan:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -14,16 +14,29 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
validate-fonts:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -68,15 +81,17 @@ jobs:
|
||||
echo '✅ No proprietary fonts found in dist'
|
||||
|
||||
validate-licenses:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
16
.github/workflows/ci-perf-report.yaml
vendored
16
.github/workflows/ci-perf-report.yaml
vendored
@@ -3,10 +3,8 @@ name: 'CI: Performance Report'
|
||||
on:
|
||||
push:
|
||||
branches: [main, core/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
|
||||
concurrency:
|
||||
group: perf-${{ github.ref }}
|
||||
@@ -16,8 +14,20 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
perf-tests:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
|
||||
15
.github/workflows/ci-size-data.yaml
vendored
15
.github/workflows/ci-size-data.yaml
vendored
@@ -16,9 +16,22 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
collect:
|
||||
changes:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
collect:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
35
.github/workflows/ci-tests-e2e.yaml
vendored
35
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -4,7 +4,6 @@ name: 'CI: Tests E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
merge_group:
|
||||
@@ -15,36 +14,20 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Detect whether e2e-relevant files changed. Required checks see "skipped"
|
||||
# (which counts as passing) when only docs/apps/storybook files are touched,
|
||||
# avoiding the stall that paths-ignore would cause.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/checkout@v6
|
||||
- name: Check for e2e-relevant changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
predicate-quantifier: 'every'
|
||||
filters: |
|
||||
e2e:
|
||||
- '**'
|
||||
- '!apps/**'
|
||||
- '!docs/**'
|
||||
- '!.storybook/**'
|
||||
- '!**/*.md'
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
setup:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should_run == 'true' }}
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -194,7 +177,7 @@ jobs:
|
||||
merge-reports:
|
||||
needs: [changes, playwright-tests-chromium-sharded]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
@@ -233,7 +216,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check E2E results
|
||||
env:
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
|
||||
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
|
||||
BROWSERS: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
@@ -251,7 +234,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
needs.changes.outputs.should-run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
@@ -278,7 +261,7 @@ jobs:
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
needs.changes.outputs.should-run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
|
||||
47
.github/workflows/ci-tests-storybook.yaml
vendored
47
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -8,10 +8,29 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
|
||||
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -30,8 +49,13 @@ jobs:
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
@@ -67,8 +91,15 @@ jobs:
|
||||
|
||||
# Chromatic deployment only for version-bump-* branches or manual triggers
|
||||
chromatic-deployment:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true'))
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
@@ -107,9 +138,15 @@ jobs:
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [storybook-build]
|
||||
needs: [changes, storybook-build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
if: |
|
||||
always()
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
15
.github/workflows/ci-tests-unit.yaml
vendored
15
.github/workflows/ci-tests-unit.yaml
vendored
@@ -4,10 +4,8 @@ name: 'CI: Tests Unit'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
@@ -15,7 +13,20 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
22
.github/workflows/ci-website-build.yaml
vendored
22
.github/workflows/ci-website-build.yaml
vendored
@@ -4,23 +4,29 @@ name: 'CI: Website Build'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
32
.github/workflows/ci-website-e2e.yaml
vendored
32
.github/workflows/ci-website-e2e.yaml
vendored
@@ -3,25 +3,29 @@ name: 'CI: Website E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
website-e2e:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
@@ -45,6 +49,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Run Playwright tests
|
||||
@@ -161,7 +167,11 @@ jobs:
|
||||
post-starting-comment:
|
||||
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
|
||||
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
needs: changes
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
|
||||
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
|
||||
@@ -86,6 +87,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Update screenshots
|
||||
@@ -137,7 +140,10 @@ jobs:
|
||||
name: 'Update Website Screenshots'
|
||||
})
|
||||
} catch (e) {
|
||||
// Label may already be removed
|
||||
if (e.status !== 404) {
|
||||
throw e
|
||||
}
|
||||
core.info('Label "Update Website Screenshots" was already removed')
|
||||
}
|
||||
|
||||
post-starting-comment:
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Skip in CI: the canonical knip check runs in ci-lint-format on every
|
||||
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
|
||||
# setup-comfyui-server, which contaminates knip's project glob with the
|
||||
# devtools copy under custom_nodes and produces false-positive failures.
|
||||
if [ -n "${CI-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run Knip with cache via package script
|
||||
pnpm knip 1>&2
|
||||
|
||||
|
||||
@@ -113,6 +113,31 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
|
||||
```html
|
||||
<script
|
||||
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
|
||||
defer
|
||||
></script>
|
||||
<div
|
||||
class="hs-form-html"
|
||||
data-region="na2"
|
||||
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
|
||||
data-portal-id="244637579"
|
||||
></div>
|
||||
```
|
||||
|
||||
The localized `/zh-CN/contact` page uses the same portal and script with form
|
||||
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
|
||||
|
||||
This keeps submission handling, validation, anti-spam updates, and field
|
||||
configuration in HubSpot. The local implementation in
|
||||
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
|
||||
renders the documented embed container.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — Astro dev server
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -26,6 +26,7 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
# robots.txt for comfy.org
|
||||
# Open to all crawlers — including AI/LLM bots — for maximum visibility
|
||||
# in AI-powered search, chat-based answer engines, and traditional search.
|
||||
# Granular UAs are listed explicitly to signal intent; rules are shared
|
||||
# via stacked user-agent records (RFC 9309 §2.2).
|
||||
|
||||
User-agent: *
|
||||
User-agent: Googlebot
|
||||
User-agent: Bingbot
|
||||
User-agent: DuckDuckBot
|
||||
User-agent: GPTBot
|
||||
User-agent: ChatGPT-User
|
||||
User-agent: OAI-SearchBot
|
||||
User-agent: Google-Extended
|
||||
User-agent: ClaudeBot
|
||||
User-agent: Claude-Web
|
||||
User-agent: anthropic-ai
|
||||
User-agent: PerplexityBot
|
||||
User-agent: Perplexity-User
|
||||
User-agent: Applebot
|
||||
User-agent: Applebot-Extended
|
||||
User-agent: Bytespider
|
||||
User-agent: Amazonbot
|
||||
User-agent: CCBot
|
||||
User-agent: Meta-ExternalAgent
|
||||
User-agent: Meta-ExternalFetcher
|
||||
User-agent: Diffbot
|
||||
Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import HubspotFormEmbed from './HubspotFormEmbed.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
@@ -17,30 +16,6 @@ function tk(suffix: string): TranslationKey {
|
||||
return `contact.form.${suffix}` as TranslationKey
|
||||
}
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const company = ref('')
|
||||
const phone = ref('')
|
||||
const selectedPackage = ref('')
|
||||
const comfyUsage = ref('')
|
||||
const lookingFor = ref('')
|
||||
|
||||
const packageOptions = [
|
||||
'packageIndividual',
|
||||
'packageTeams',
|
||||
'packageEnterprise'
|
||||
] as const
|
||||
|
||||
const usageOptions = [
|
||||
'usingYesProduction',
|
||||
'usingYesTesting',
|
||||
'usingNotYet',
|
||||
'usingOtherTools'
|
||||
] as const
|
||||
|
||||
const inputClass =
|
||||
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
|
||||
|
||||
const sectionRef = ref<HTMLElement>()
|
||||
const badgeRef = ref<HTMLElement>()
|
||||
const headingRef = ref<HTMLElement>()
|
||||
@@ -55,10 +30,6 @@ useHeroAnimation({
|
||||
video: formRef,
|
||||
parallax: false
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
// TODO: implement form submission
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,160 +76,7 @@ function handleSubmit() {
|
||||
|
||||
<!-- Right column: form -->
|
||||
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<!-- First Name + Last Name -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('firstName'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('firstNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lastName'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('lastNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company + Phone -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('company'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="company"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('companyPlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('phone'), locale) }}
|
||||
</label>
|
||||
<input v-model="phone" type="tel" :class="inputClass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package selection -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('packageQuestion'), locale) }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="opt in packageOptions"
|
||||
:key="opt"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
|
||||
selectedPackage === opt
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="selectedPackage"
|
||||
type="radio"
|
||||
name="package"
|
||||
:value="opt"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
selectedPackage === opt
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-primary-warm-gray/40'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="selectedPackage === opt"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
{{ t(tk(opt), locale) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfy usage -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('usingComfy'), locale) }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<label
|
||||
v-for="opt in usageOptions"
|
||||
:key="opt"
|
||||
class="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
comfyUsage === opt
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="comfyUsage === opt"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="comfyUsage"
|
||||
type="radio"
|
||||
:value="opt"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(tk(opt), locale) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Looking for -->
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lookingFor'), locale) }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="lookingFor"
|
||||
:placeholder="t(tk('lookingForPlaceholder'), locale)"
|
||||
:class="cn(inputClass, 'min-h-24 resize-y')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div>
|
||||
<BrandButton type="submit" variant="outline" size="sm">
|
||||
{{ t(tk('submit'), locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
<HubspotFormEmbed :locale />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
126
apps/website/src/components/contact/HubspotFormEmbed.vue
Normal file
126
apps/website/src/components/contact/HubspotFormEmbed.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const HUBSPOT_CONTACT_PORTAL_ID = '244637579'
|
||||
const HUBSPOT_CONTACT_REGION = 'na2'
|
||||
const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed'
|
||||
const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js`
|
||||
|
||||
const hubspotContactFormIds: Record<Locale, string> = {
|
||||
en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262',
|
||||
'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93'
|
||||
}
|
||||
|
||||
const hasEmbedLoadError = ref(false)
|
||||
const hubspotContactFormId = computed(() => hubspotContactFormIds[locale])
|
||||
|
||||
const hubspotFormStyles: Record<`--${string}`, string> = {
|
||||
'--hsf-global__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-global__color': '#c2bfb9',
|
||||
'--hsf-background__background-color': '#211927',
|
||||
'--hsf-background__border-width': '0',
|
||||
'--hsf-background__padding': '0',
|
||||
'--hsf-button__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-button__font-size': '14px',
|
||||
'--hsf-button__color': '#211927',
|
||||
'--hsf-button__background-color': '#f2ff59',
|
||||
'--hsf-button__border-radius': '16px',
|
||||
'--hsf-button__padding': '10px 24px',
|
||||
'--hsf-richtext__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-richtext__color': '#c2bfb9',
|
||||
'--hsf-heading__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-heading__color': '#c2bfb9',
|
||||
'--hsf-field-label__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-label__font-size': '12px',
|
||||
'--hsf-field-label__color': '#c2bfb9',
|
||||
'--hsf-field-description__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-description__color': '#c2bfb9',
|
||||
'--hsf-field-footer__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-footer__color': '#c2bfb9',
|
||||
'--hsf-field-input__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-input__color': '#c2bfb9',
|
||||
'--hsf-field-input__background-color': '#2a2230',
|
||||
'--hsf-field-input__placeholder-color': '#585159',
|
||||
'--hsf-field-input__border-color': '#3b3539',
|
||||
'--hsf-field-input__border-width': '1px',
|
||||
'--hsf-field-input__border-style': 'solid',
|
||||
'--hsf-field-input__border-radius': '16px',
|
||||
'--hsf-field-input__padding': '16px',
|
||||
'--hsf-field-textarea__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-field-textarea__color': '#c2bfb9',
|
||||
'--hsf-field-textarea__background-color': '#2a2230',
|
||||
'--hsf-field-textarea__placeholder-color': '#585159',
|
||||
'--hsf-field-textarea__border-color': '#3b3539',
|
||||
'--hsf-field-textarea__border-width': '1px',
|
||||
'--hsf-field-textarea__border-style': 'solid',
|
||||
'--hsf-field-textarea__border-radius': '16px',
|
||||
'--hsf-field-textarea__padding': '16px',
|
||||
'--hsf-field-checkbox__color': '#c2bfb9',
|
||||
'--hsf-field-checkbox__background-color': '#2a2230',
|
||||
'--hsf-field-checkbox__border-color': '#464147',
|
||||
'--hsf-field-checkbox__border-width': '1px',
|
||||
'--hsf-field-checkbox__border-style': 'solid',
|
||||
'--hsf-field-radio__color': '#c2bfb9',
|
||||
'--hsf-field-radio__background-color': '#2a2230',
|
||||
'--hsf-field-radio__border-color': '#464147',
|
||||
'--hsf-field-radio__border-width': '1px',
|
||||
'--hsf-field-radio__border-style': 'solid',
|
||||
'--hsf-erroralert__font-family': "'PP Formula', sans-serif",
|
||||
'--hsf-infoalert__font-family': "'PP Formula', sans-serif"
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = HUBSPOT_CONTACT_SCRIPT_ID
|
||||
script.src = HUBSPOT_CONTACT_SCRIPT_SRC
|
||||
script.defer = true
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
hasEmbedLoadError.value = true
|
||||
script.remove()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
document.head.append(script)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[640px] w-full">
|
||||
<p
|
||||
v-if="hasEmbedLoadError"
|
||||
class="text-primary-comfy-canvas text-sm/6"
|
||||
role="status"
|
||||
>
|
||||
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
|
||||
<a
|
||||
class="text-primary-comfy-yellow underline"
|
||||
href="mailto:hello@comfy.org"
|
||||
>
|
||||
hello@comfy.org
|
||||
</a>
|
||||
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
:key="hubspotContactFormId"
|
||||
class="hs-form-html"
|
||||
:style="hubspotFormStyles"
|
||||
:data-region="HUBSPOT_CONTACT_REGION"
|
||||
:data-form-id="hubspotContactFormId"
|
||||
:data-portal-id="HUBSPOT_CONTACT_PORTAL_ID"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3298,82 +3298,13 @@ const translations = {
|
||||
en: 'Find your answer here',
|
||||
'zh-CN': '在这里找到答案'
|
||||
},
|
||||
'contact.form.firstName': {
|
||||
en: 'First name',
|
||||
'zh-CN': '名'
|
||||
'contact.form.embedLoadErrorPrefix': {
|
||||
en: 'Unable to load the contact form. Email us at',
|
||||
'zh-CN': '联系表单无法加载。请发送邮件至'
|
||||
},
|
||||
'contact.form.lastName': {
|
||||
en: 'Last Name',
|
||||
'zh-CN': '姓'
|
||||
},
|
||||
'contact.form.company': {
|
||||
en: 'Company',
|
||||
'zh-CN': '公司'
|
||||
},
|
||||
'contact.form.phone': {
|
||||
en: 'Phone Number (optional)',
|
||||
'zh-CN': '电话号码(可选)'
|
||||
},
|
||||
'contact.form.packageQuestion': {
|
||||
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
|
||||
'zh-CN':
|
||||
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K)、个人套餐或团队套餐?'
|
||||
},
|
||||
'contact.form.packageIndividual': {
|
||||
en: 'INDIVIDUAL',
|
||||
'zh-CN': '个人'
|
||||
},
|
||||
'contact.form.packageTeams': {
|
||||
en: 'TEAMS',
|
||||
'zh-CN': '团队'
|
||||
},
|
||||
'contact.form.packageEnterprise': {
|
||||
en: 'ENTERPRISE',
|
||||
'zh-CN': '企业'
|
||||
},
|
||||
'contact.form.usingComfy': {
|
||||
en: 'Are you /your team currently using Comfy?',
|
||||
'zh-CN': '您/您的团队目前是否在使用 Comfy?'
|
||||
},
|
||||
'contact.form.usingYesProduction': {
|
||||
en: 'Yes, in production',
|
||||
'zh-CN': '是,在生产环境中'
|
||||
},
|
||||
'contact.form.usingYesTesting': {
|
||||
en: 'Yes, testing / experimenting',
|
||||
'zh-CN': '是,测试/实验中'
|
||||
},
|
||||
'contact.form.usingNotYet': {
|
||||
en: 'Not yet, evaluating',
|
||||
'zh-CN': '尚未使用,评估中'
|
||||
},
|
||||
'contact.form.usingOtherTools': {
|
||||
en: 'Not using Comfy yet, but using other GenAI tools',
|
||||
'zh-CN': '尚未使用 Comfy,但在使用其他 GenAI 工具'
|
||||
},
|
||||
'contact.form.lookingFor': {
|
||||
en: 'What are you looking for?',
|
||||
'zh-CN': '您在寻找什么?'
|
||||
},
|
||||
'contact.form.lookingForPlaceholder': {
|
||||
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
|
||||
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
|
||||
},
|
||||
'contact.form.submit': {
|
||||
en: 'SUBMIT',
|
||||
'zh-CN': '提交'
|
||||
},
|
||||
'contact.form.firstNamePlaceholder': {
|
||||
en: 'Jane',
|
||||
'zh-CN': 'Jane'
|
||||
},
|
||||
'contact.form.lastNamePlaceholder': {
|
||||
en: 'Smith',
|
||||
'zh-CN': 'Smith'
|
||||
},
|
||||
'contact.form.companyPlaceholder': {
|
||||
en: 'jane@acme.org',
|
||||
'zh-CN': 'jane@acme.org'
|
||||
'contact.form.embedLoadErrorSuffix': {
|
||||
en: "and we'll route your request.",
|
||||
'zh-CN': '我们会为您处理请求。'
|
||||
},
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
|
||||
@@ -133,9 +133,15 @@ const websiteJsonLd = {
|
||||
<script>
|
||||
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
|
||||
import { ScrollTrigger } from '../scripts/gsapSetup'
|
||||
import { initPostHog, capturePageview } from '../scripts/posthog'
|
||||
|
||||
initSmoothScroll()
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
initPostHog()
|
||||
document.addEventListener('astro:page-load', capturePageview)
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
ScrollTrigger.refresh()
|
||||
})
|
||||
|
||||
36
apps/website/src/scripts/posthog.ts
Normal file
36
apps/website/src/scripts/posthog.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
const POSTHOG_API_HOST =
|
||||
import.meta.env.PUBLIC_POSTHOG_API_HOST ?? 'https://t.comfy.org'
|
||||
const POSTHOG_UI_HOST =
|
||||
import.meta.env.PUBLIC_POSTHOG_UI_HOST ?? 'https://us.posthog.com'
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initPostHog() {
|
||||
if (initialized || typeof window === 'undefined' || !POSTHOG_KEY) return
|
||||
try {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_API_HOST,
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
console.error('PostHog init failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function capturePageview() {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('$pageview')
|
||||
} catch (error) {
|
||||
console.error('PostHog pageview capture failed', error)
|
||||
}
|
||||
}
|
||||
36
apps/website/src/utils/github.test.ts
Normal file
36
apps/website/src/utils/github.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
else process.env.WEBSITE_GITHUB_STARS_OVERRIDE = savedOverride
|
||||
})
|
||||
|
||||
it('uses the build-time override without calling GitHub', async () => {
|
||||
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when the build-time override is malformed', async () => {
|
||||
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStarCount', () => {
|
||||
it('formats the visual-test override to match committed snapshots', () => {
|
||||
expect(formatStarCount(110000)).toBe('110K')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,9 @@ export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
@@ -25,3 +28,17 @@ export function formatStarCount(count: number): string {
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
function readGitHubStarsOverride(): number | undefined {
|
||||
const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
if (rawCount === undefined || rawCount === '') return undefined
|
||||
|
||||
const count = Number(rawCount)
|
||||
if (!Number.isSafeInteger(count) || count < 0) {
|
||||
throw new Error(
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ pnpm test:browser:local # Run all tests
|
||||
pnpm test:browser:local widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Slowing the browser down for debugging
|
||||
|
||||
When running with `--headed` (or `--ui`), set `SLOW_MO` to a millisecond delay
|
||||
to slow every Playwright action down so you can watch what is happening. The
|
||||
delay only applies when `PLAYWRIGHT_LOCAL` is set (the default for the
|
||||
`pnpm test:browser:local` script).
|
||||
|
||||
```bash
|
||||
SLOW_MO=250 pnpm test:browser:local --headed widget.spec.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Browser tests in this project follow a specific organization pattern:
|
||||
|
||||
27
browser_tests/assets/3d/load3d_missing_model.json
Normal file
27
browser_tests/assets/3d/load3d_missing_model.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Preview3D",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 600],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3D",
|
||||
"Last Time Model File": "nonexistent_model.glb"
|
||||
},
|
||||
"widgets_values": ["nonexistent_model.glb"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "offset": [0, 0], "scale": 1 } },
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -30,6 +30,13 @@ export class VueNodeHelpers {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inner wrapper element of a Vue node.
|
||||
*/
|
||||
getNodeInnerWrapper(nodeId: string): Locator {
|
||||
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
@@ -119,10 +126,9 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -133,6 +139,15 @@ export class VueNodeHelpers {
|
||||
)
|
||||
}
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
|
||||
54
browser_tests/fixtures/components/WidgetBoundingBox.ts
Normal file
54
browser_tests/fixtures/components/WidgetBoundingBox.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class BoundingBoxCoordinate {
|
||||
public readonly root: Locator
|
||||
public readonly input: Locator
|
||||
public readonly incrementButton: Locator
|
||||
public readonly decrementButton: Locator
|
||||
|
||||
constructor(root: Locator) {
|
||||
this.root = root
|
||||
this.input = root.locator('input')
|
||||
this.incrementButton = root.getByTestId(TestIds.widgets.increment)
|
||||
this.decrementButton = root.getByTestId(TestIds.widgets.decrement)
|
||||
}
|
||||
|
||||
async type(value: string | number): Promise<void> {
|
||||
await this.input.fill(String(value))
|
||||
await this.input.press('Enter')
|
||||
}
|
||||
|
||||
async focus(): Promise<void> {
|
||||
await this.input.focus()
|
||||
}
|
||||
|
||||
async increment(): Promise<void> {
|
||||
await this.incrementButton.click()
|
||||
}
|
||||
|
||||
async decrement(): Promise<void> {
|
||||
await this.decrementButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class WidgetBoundingBoxFixture {
|
||||
public readonly root: Locator
|
||||
public readonly x: BoundingBoxCoordinate
|
||||
public readonly y: BoundingBoxCoordinate
|
||||
public readonly width: BoundingBoxCoordinate
|
||||
public readonly height: BoundingBoxCoordinate
|
||||
|
||||
constructor(parent: Locator) {
|
||||
this.root = parent.getByTestId('bounding-box')
|
||||
this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x'))
|
||||
this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y'))
|
||||
this.width = new BoundingBoxCoordinate(
|
||||
this.root.getByTestId('bounding-box-width')
|
||||
)
|
||||
this.height = new BoundingBoxCoordinate(
|
||||
this.root.getByTestId('bounding-box-height')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,17 @@ import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
type DragAndDropOptions = {
|
||||
fileName?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
}
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async dragAndDropExternalResource(
|
||||
options: DragAndDropOptions = {}
|
||||
options: {
|
||||
fileName?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
@@ -145,14 +143,17 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropFile(
|
||||
fileName: string,
|
||||
options: DragAndDropOptions = {}
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: DragAndDropOptions = {}
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,21 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
export async function enableErrorsOverlay(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Dismiss the error overlay (the floating dialog with the dismiss button). */
|
||||
export async function dismissErrorOverlay(comfyPage: ComfyPage): Promise<void> {
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
}
|
||||
|
||||
export async function loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
@@ -1,9 +1,35 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
*/
|
||||
export function buildKSamplerError(
|
||||
type: NodeError['errors'][number]['type'],
|
||||
inputName: string,
|
||||
message: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type,
|
||||
message,
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for simulating prompt execution in e2e tests.
|
||||
*/
|
||||
@@ -16,13 +42,23 @@ export class ExecutionHelper {
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
private readonly ws: WebSocketRoute
|
||||
private readonly ws?: WebSocketRoute
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.command = comfyPage.command
|
||||
this.assets = comfyPage.assets
|
||||
}
|
||||
|
||||
private requireWs(): WebSocketRoute {
|
||||
if (!this.ws) {
|
||||
throw new Error(
|
||||
'ExecutionHelper was constructed without a WebSocketRoute; ' +
|
||||
'pass `ws` to use methods that send WS frames.'
|
||||
)
|
||||
}
|
||||
return this.ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||
* the synthetic job ID.
|
||||
@@ -39,7 +75,7 @@ export class ExecutionHelper {
|
||||
})
|
||||
|
||||
await this.page.route(
|
||||
'**/api/prompt',
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -60,6 +96,31 @@ export class ExecutionHelper {
|
||||
return jobId
|
||||
}
|
||||
|
||||
async mockValidationFailure(
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Promise<void> {
|
||||
const response: PromptResponse = {
|
||||
node_errors: nodeErrors,
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Prompt outputs failed validation',
|
||||
details: ''
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||
@@ -89,12 +150,12 @@ export class ExecutionHelper {
|
||||
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||
|
||||
this.ws.send(Buffer.from(buf))
|
||||
this.requireWs().send(Buffer.from(buf))
|
||||
}
|
||||
|
||||
/** Send `execution_start` WS event. */
|
||||
executionStart(jobId: string): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -104,7 +165,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `executing` WS event to signal which node is currently running. */
|
||||
executing(jobId: string, nodeId: string | null): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { prompt_id: jobId, node: nodeId }
|
||||
@@ -118,7 +179,7 @@ export class ExecutionHelper {
|
||||
nodeId: string,
|
||||
output: Record<string, unknown>
|
||||
): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'executed',
|
||||
data: {
|
||||
@@ -133,7 +194,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_success` WS event. */
|
||||
executionSuccess(jobId: string): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_success',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -143,7 +204,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_error` WS event. */
|
||||
executionError(jobId: string, nodeId: string, message: string): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_error',
|
||||
data: {
|
||||
@@ -161,7 +222,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||
@@ -201,7 +262,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `status` WS event to update queue count. */
|
||||
status(queueRemaining: number): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { cleanupFakeModel } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -282,6 +282,57 @@ test.describe('Load3D', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load3D silent 404 on missing output model', () => {
|
||||
test('Does not show an error toast when the output model file is missing (404)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept model fetch and return 404 to simulate a missing output file
|
||||
// (e.g. shared workflow opened on a machine that never ran it)
|
||||
await comfyPage.page.route('**/view?**', (route) =>
|
||||
route.fulfill({ status: 404, body: 'Not Found' })
|
||||
)
|
||||
|
||||
// This workflow has a Preview3D node with Last Time Model File set,
|
||||
// triggering the loadFolder: 'output' + silentOnNotFound: true path.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
// Wait for the 404 response before asserting — gives the load attempt time
|
||||
// to complete without using waitForTimeout
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
|
||||
await responsePromise
|
||||
|
||||
await expect(
|
||||
comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Shows an error toast when a non-404 error occurs loading the output model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept with a 500 to simulate a real server error (not 404) — toast must appear
|
||||
await comfyPage.page.route('**/view?**', (route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
|
||||
await responsePromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.toast.visibleToasts
|
||||
.filter({ hasText: 'Error loading model' })
|
||||
.count(),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load3D initialization failure', () => {
|
||||
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -267,5 +267,65 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
if (mode === 'vue') {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
} else {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
}
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
const subgraphId = await comfyPage.subgraph.getActiveGraphId()
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.state.ghostNodeId != null
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), {
|
||||
message:
|
||||
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getActiveGraphId())
|
||||
.toBe(subgraphId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
|
||||
)
|
||||
.toBeNull()
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
cleanupFakeModel,
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -42,8 +42,11 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
// Selection toolbox should be visible with multiple nodes selected
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
// Border is now drawn on canvas, check via screenshot
|
||||
// Allow small anti-aliasing variance on the canvas-drawn selection border
|
||||
// (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-multiple-nodes-border.png'
|
||||
'selection-toolbox-multiple-nodes-border.png',
|
||||
{ maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -364,34 +364,6 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
|
||||
})
|
||||
|
||||
test('Can upload workflow to library by drag and drop', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
|
||||
expect(await workflowsTab.getTopLevelSavedWorkflowNames()).not.toContain(
|
||||
'default'
|
||||
)
|
||||
|
||||
const sidebarBox = (await comfyPage.page
|
||||
.locator('.workflows-sidebar-tab')
|
||||
.boundingBox())!
|
||||
const dropPosition = {
|
||||
x: sidebarBox.x + sidebarBox.width / 2,
|
||||
y: sidebarBox.y + sidebarBox.height / 2
|
||||
}
|
||||
await comfyPage.dragDrop.dragAndDropFile('default.json', {
|
||||
dropPosition,
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
|
||||
.toContain('default')
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json'
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -42,31 +40,6 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
@@ -525,29 +498,4 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,10 +121,7 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'vue-groups-create-group.png'
|
||||
)
|
||||
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should allow fitting group to contents', async ({ comfyPage }) => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,9 +1,25 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
enableErrorsOverlay
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -11,24 +27,202 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'UNKNOWN NODE' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('should display error state when node causes execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
const raiseErrorId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('Raise Error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const raiseErrorNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'Raise Error' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(raiseErrorId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test.describe('validation errors', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
test('shows error ring when a validation error is returned for a node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'value_bigger_than_max',
|
||||
'steps',
|
||||
'steps: 99999 is bigger than max 10000'
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user edits an out-of-range number widget back into range', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
await test.step('queue with out-of-range steps to surface the error', async () => {
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'value_bigger_than_max',
|
||||
'steps',
|
||||
'steps: 99999 is bigger than max 10000'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('edit steps widget so the new value is within range', async () => {
|
||||
const stepsWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
'KSampler',
|
||||
'steps'
|
||||
)
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
// ScrubableNumberInput commits on blur — explicit blur avoids a race
|
||||
// with the keyup-Enter handler in case Enter is consumed elsewhere.
|
||||
await controls.input.fill('25')
|
||||
await controls.input.blur()
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user picks a different combo option', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
await test.step('queue with invalid sampler to surface the error', async () => {
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'value_not_in_list',
|
||||
'sampler_name',
|
||||
'sampler_name: bogus_sampler is not in list'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('select a different sampler option', async () => {
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
'KSampler',
|
||||
'sampler_name',
|
||||
'dpmpp_2m'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when an interior node is missing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
|
||||
'Subgraph with Missing Node'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when an interior node has a missing model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
|
||||
'Subgraph with Missing Model'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when an interior node fails execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph parent must mount before injecting WS execution_error'
|
||||
).toBeVisible()
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
exec.executionError(
|
||||
'mocked-prompt',
|
||||
INNER_EXECUTION_ID,
|
||||
'boom inside the subgraph'
|
||||
)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('parent subgraph node shows error ring when interior node has a validation error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Validation errors are keyed by execution id, so an interior error
|
||||
// ("2:1") must propagate the ring up to the root-level subgraph
|
||||
// container ("2") via errorAncestorExecutionIds.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
await expect(innerWrapper).toBeVisible()
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'value_bigger_than_max',
|
||||
'steps',
|
||||
'steps: 99999 is bigger than max 10000'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -2,6 +2,10 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
|
||||
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
|
||||
|
||||
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -20,15 +24,11 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
function getNode(comfyPage: ComfyPage) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
function getWidgets(comfyPage: ComfyPage) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
@@ -58,20 +58,41 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
await node.getByText(SHOW_ADVANCED_INPUTS).click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await node.getByText(HIDE_ADVANCED_INPUTS).click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should hide advanced footer button while collapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
|
||||
const vueNode =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeHidden()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -92,6 +113,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText('Show advanced inputs')).toBeHidden()
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
185
browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts
Normal file
185
browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetBoundingBoxFixture } from '@e2e/fixtures/components/WidgetBoundingBox'
|
||||
|
||||
const NODE_ID = '1'
|
||||
|
||||
test.describe('Widget Bounding Box', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
})
|
||||
|
||||
test(
|
||||
'Renders all four coordinate inputs with workflow values',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await expect(boundingBox.root).toBeVisible()
|
||||
await expect(boundingBox.x.input).toHaveValue('0')
|
||||
await expect(boundingBox.y.input).toHaveValue('0')
|
||||
await expect(boundingBox.width.input).toHaveValue('512')
|
||||
await expect(boundingBox.height.input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
|
||||
test('Typing into each coordinate updates only that coordinate', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await test.step('type X', async () => {
|
||||
await boundingBox.x.type(25)
|
||||
await expect(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.y.input).toHaveValue('0')
|
||||
await expect.soft(boundingBox.width.input).toHaveValue('512')
|
||||
await expect.soft(boundingBox.height.input).toHaveValue('512')
|
||||
})
|
||||
|
||||
await test.step('type Y', async () => {
|
||||
await boundingBox.y.type(40)
|
||||
await expect(boundingBox.y.input).toHaveValue('40')
|
||||
await expect.soft(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.width.input).toHaveValue('512')
|
||||
await expect.soft(boundingBox.height.input).toHaveValue('512')
|
||||
})
|
||||
|
||||
await test.step('type Width', async () => {
|
||||
await boundingBox.width.type(200)
|
||||
await expect(boundingBox.width.input).toHaveValue('200')
|
||||
await expect.soft(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.y.input).toHaveValue('40')
|
||||
await expect.soft(boundingBox.height.input).toHaveValue('512')
|
||||
})
|
||||
|
||||
await test.step('type Height', async () => {
|
||||
await boundingBox.height.type(300)
|
||||
await expect(boundingBox.height.input).toHaveValue('300')
|
||||
await expect.soft(boundingBox.x.input).toHaveValue('25')
|
||||
await expect.soft(boundingBox.y.input).toHaveValue('40')
|
||||
await expect.soft(boundingBox.width.input).toHaveValue('200')
|
||||
})
|
||||
})
|
||||
|
||||
test('Negative X/Y values are clamped to min=0', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.x.type(50)
|
||||
await expect(boundingBox.x.input).toHaveValue('50')
|
||||
await boundingBox.x.type('-10')
|
||||
await expect(boundingBox.x.input).toHaveValue('0')
|
||||
|
||||
await boundingBox.y.type(75)
|
||||
await expect(boundingBox.y.input).toHaveValue('75')
|
||||
await boundingBox.y.type('-50')
|
||||
await expect(boundingBox.y.input).toHaveValue('0')
|
||||
})
|
||||
|
||||
test('Width/Height values below 1 are clamped to min=1', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.width.type(0)
|
||||
await expect(boundingBox.width.input).toHaveValue('1')
|
||||
|
||||
await boundingBox.height.type('-5')
|
||||
await expect(boundingBox.height.input).toHaveValue('1')
|
||||
})
|
||||
|
||||
test('Increment and decrement buttons change coordinate by step', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await test.step('increment X from 0 to 2', async () => {
|
||||
await boundingBox.x.increment()
|
||||
await boundingBox.x.increment()
|
||||
await expect(boundingBox.x.input).toHaveValue('2')
|
||||
})
|
||||
|
||||
await test.step('decrement X from 2 to 1', async () => {
|
||||
await boundingBox.x.decrement()
|
||||
await expect(boundingBox.x.input).toHaveValue('1')
|
||||
})
|
||||
|
||||
await test.step('decrement Width from 512 to 510', async () => {
|
||||
await boundingBox.width.decrement()
|
||||
await boundingBox.width.decrement()
|
||||
await expect(boundingBox.width.input).toHaveValue('510')
|
||||
})
|
||||
|
||||
await test.step('increment Height from 512 to 513', async () => {
|
||||
await boundingBox.height.increment()
|
||||
await expect(boundingBox.height.input).toHaveValue('513')
|
||||
})
|
||||
})
|
||||
|
||||
test('Arrow keys step the focused input; PageUp/PageDown step by 10', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.width.focus()
|
||||
|
||||
await boundingBox.width.input.press('ArrowUp')
|
||||
await expect(boundingBox.width.input).toHaveValue('513')
|
||||
|
||||
await boundingBox.width.input.press('ArrowDown')
|
||||
await boundingBox.width.input.press('ArrowDown')
|
||||
await expect(boundingBox.width.input).toHaveValue('511')
|
||||
|
||||
await boundingBox.width.input.press('PageUp')
|
||||
await expect(boundingBox.width.input).toHaveValue('521')
|
||||
|
||||
await boundingBox.width.input.press('PageDown')
|
||||
await expect(boundingBox.width.input).toHaveValue('511')
|
||||
})
|
||||
|
||||
test('Decrement button is disabled when value equals min', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await test.step('X at 0 disables decrement', async () => {
|
||||
await expect(boundingBox.x.input).toHaveValue('0')
|
||||
await expect(boundingBox.x.decrementButton).toBeDisabled()
|
||||
await expect(boundingBox.x.incrementButton).toBeEnabled()
|
||||
})
|
||||
|
||||
await test.step('Width at 1 disables decrement', async () => {
|
||||
await boundingBox.width.type(1)
|
||||
await expect(boundingBox.width.input).toHaveValue('1')
|
||||
await expect(boundingBox.width.decrementButton).toBeDisabled()
|
||||
await expect(boundingBox.width.incrementButton).toBeEnabled()
|
||||
})
|
||||
|
||||
await test.step('Incrementing X re-enables decrement', async () => {
|
||||
await boundingBox.x.increment()
|
||||
await expect(boundingBox.x.decrementButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test('Non-numeric input reverts to previous value on blur', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
|
||||
const boundingBox = new WidgetBoundingBoxFixture(node)
|
||||
|
||||
await boundingBox.x.type(42)
|
||||
await expect(boundingBox.x.input).toHaveValue('42')
|
||||
|
||||
await boundingBox.x.input.fill('not a number')
|
||||
await boundingBox.x.input.blur()
|
||||
await expect(boundingBox.x.input).toHaveValue('42')
|
||||
})
|
||||
})
|
||||
205
browser_tests/tests/workflowSettings.spec.ts
Normal file
205
browser_tests/tests/workflowSettings.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Page, Request } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
function isUserdataWorkflowSave(request: Request): boolean {
|
||||
return (
|
||||
request.method() === 'POST' &&
|
||||
/\/api\/userdata\/workflows%2F[^?]+\.json/.test(request.url())
|
||||
)
|
||||
}
|
||||
|
||||
function collectSaves(page: Page): Disposable & { readonly saves: string[] } {
|
||||
const saves: string[] = []
|
||||
function onRequest(request: Request) {
|
||||
if (isUserdataWorkflowSave(request)) saves.push(request.url())
|
||||
}
|
||||
page.on('request', onRequest)
|
||||
return {
|
||||
saves,
|
||||
[Symbol.dispose]() {
|
||||
page.off('request', onRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSave(page: Page, timeout: number): Promise<boolean> {
|
||||
return page
|
||||
.waitForRequest(isUserdataWorkflowSave, { timeout })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the first node so the change tracker dispatches `graphChanged`.
|
||||
*/
|
||||
async function triggerGraphChange(comfyPage: ComfyPage): Promise<void> {
|
||||
const node = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!node) throw new Error('Default workflow expected to have a first node')
|
||||
const titlePos = await node.getTitlePosition()
|
||||
const absFrom = await comfyPage.canvasOps.toAbsolute(titlePos)
|
||||
const absTo = { x: absFrom.x + 120, y: absFrom.y + 120 }
|
||||
await comfyPage.canvasOps.dragAndDrop(absFrom, absTo)
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function setupAutoSaveAfterDelay(
|
||||
comfyPage: ComfyPage,
|
||||
delayMs: number
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow('autosave')
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', delayMs)
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'after delay')
|
||||
}
|
||||
|
||||
test.describe('Workflow settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Workflow.AutoSave', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
|
||||
})
|
||||
|
||||
test("'off' does not save modified workflow after delay", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('autosave')
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', 50)
|
||||
|
||||
await triggerGraphChange(comfyPage)
|
||||
|
||||
// Within a window an order of magnitude longer than AutoSaveDelay, the
|
||||
// off watcher must not write back.
|
||||
const sawSave = await waitForSave(comfyPage.page, 500)
|
||||
expect(
|
||||
sawSave,
|
||||
'AutoSave=off must not write back after a graph change'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("'after delay' saves the workflow after a graph change", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupAutoSaveAfterDelay(comfyPage, 100)
|
||||
|
||||
const savePromise = comfyPage.page.waitForRequest(
|
||||
isUserdataWorkflowSave,
|
||||
{ timeout: 4000 }
|
||||
)
|
||||
await triggerGraphChange(comfyPage)
|
||||
await savePromise
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Workflow.AutoSaveDelay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off')
|
||||
})
|
||||
|
||||
test('long delay defers save until at least the configured duration has elapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const LONG_DELAY_MS = 1000
|
||||
const EARLY_WINDOW_MS = 500
|
||||
|
||||
await setupAutoSaveAfterDelay(comfyPage, LONG_DELAY_MS)
|
||||
|
||||
using tracker = collectSaves(comfyPage.page)
|
||||
|
||||
await triggerGraphChange(comfyPage)
|
||||
|
||||
// No save fires within a window comfortably shorter than the delay.
|
||||
const sawEarlySave = await waitForSave(comfyPage.page, EARLY_WINDOW_MS)
|
||||
expect(
|
||||
sawEarlySave,
|
||||
`No save should fire within ${EARLY_WINDOW_MS}ms when the configured delay is ${LONG_DELAY_MS}ms`
|
||||
).toBe(false)
|
||||
|
||||
// Eventually the save does fire.
|
||||
await comfyPage.page.waitForRequest(isUserdataWorkflowSave, {
|
||||
timeout: 3000
|
||||
})
|
||||
expect(tracker.saves).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
|
||||
async function getSerializedNodeIds(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeId[]> {
|
||||
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
|
||||
(n) => n.id
|
||||
)
|
||||
}
|
||||
|
||||
function ascendingById(ids: NodeId[]): NodeId[] {
|
||||
return [...ids].sort((a, b) => Number(a) - Number(b))
|
||||
}
|
||||
|
||||
test('false preserves the graph insertion order', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
false
|
||||
)
|
||||
const ids = await getSerializedNodeIds(comfyPage)
|
||||
|
||||
expect(ids, 'default workflow nodes already sorted').not.toEqual(
|
||||
ascendingById(ids)
|
||||
)
|
||||
})
|
||||
|
||||
test('true sorts nodes by id ascending', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
true
|
||||
)
|
||||
const ids = await getSerializedNodeIds(comfyPage)
|
||||
expect(ids).toEqual(ascendingById(ids))
|
||||
})
|
||||
|
||||
test('toggling sort preserves node set in both workflow JSON and API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
false
|
||||
)
|
||||
const expectedIds = ascendingById(await getSerializedNodeIds(comfyPage))
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.SortNodeIdOnSave',
|
||||
true
|
||||
)
|
||||
|
||||
// Workflow JSON nodes (the surface controlled by SortNodeIdOnSave) must
|
||||
// still contain the same set of ids — sort changes order, not membership.
|
||||
expect(ascendingById(await getSerializedNodeIds(comfyPage))).toEqual(
|
||||
expectedIds
|
||||
)
|
||||
|
||||
// The API prompt is independently derived from execution order, but it
|
||||
// must enumerate the same node set regardless of the sort flag.
|
||||
const apiPrompt: ComfyApiWorkflow =
|
||||
await comfyPage.workflow.getExportedWorkflow({ api: true })
|
||||
expect(ascendingById(Object.keys(apiPrompt).map(Number))).toEqual(
|
||||
expectedIds
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -257,6 +257,8 @@ it('should validate node definition', () => {
|
||||
|
||||
## Mocking Composables with Reactive State
|
||||
|
||||
> **Don't mock `vue-i18n`.** Mount with a real `createI18n` plugin instance instead — see [Don't Mock `vue-i18n` in `vitest-patterns.md`](./vitest-patterns.md#dont-mock-vue-i18n--use-a-real-plugin). This section applies to composables you own.
|
||||
|
||||
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
|
||||
|
||||
### Rules
|
||||
|
||||
@@ -30,9 +30,42 @@ describe('MyStore', () => {
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## i18n in Component Tests
|
||||
## Don't Mock `vue-i18n` — Use a Real Plugin
|
||||
|
||||
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||
Mount with a real `createI18n` instance instead of mocking `vue-i18n`. The plugin is cheap, owned by a third party (don't mock what you don't own), and a real instance exercises the same translation key resolution and pluralization logic that production uses.
|
||||
|
||||
This applies to **all tests** that touch a component or composable calling `useI18n()` — not just component tests.
|
||||
|
||||
```typescript
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} } // empty — assertions key off the translation key, not the rendered string
|
||||
})
|
||||
|
||||
// Component tests: pass via global plugins
|
||||
mount(MyComponent, { global: { plugins: [i18n] } })
|
||||
|
||||
// Composable tests: provide via a host component (see useMediaAssetActions.test.ts pattern)
|
||||
const app = createApp(HostComponent)
|
||||
app.use(i18n)
|
||||
```
|
||||
|
||||
Real example: [`src/components/searchbox/v2/__test__/testUtils.ts`](../../src/components/searchbox/v2/__test__/testUtils.ts) exports a shared `testI18n` instance.
|
||||
|
||||
### Asserting on translation keys
|
||||
|
||||
With empty messages, `t('foo.bar')` returns `'foo.bar'` (the key). Assert against the key directly — no need to mock `t`:
|
||||
|
||||
```typescript
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'mediaAsset.selection.exportStarted' })
|
||||
)
|
||||
```
|
||||
|
||||
For pluralization / interpolation arguments, spy on the consumer (e.g. the toast `add` fn) and inspect the captured payload, rather than spying on `t` itself.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.13",
|
||||
"version": "1.44.15",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -83,6 +83,7 @@
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
@@ -113,6 +114,7 @@
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
|
||||
@@ -27,6 +27,23 @@
|
||||
--color-smoke-700: #a0a0a0;
|
||||
--color-smoke-800: #8a8a8a;
|
||||
|
||||
/* Plum */
|
||||
--color-plum-300: #afa3db;
|
||||
--color-plum-400: #8d7fc5;
|
||||
--color-plum-500: #6b5ca8;
|
||||
--color-plum-600: #49378b;
|
||||
|
||||
/* Ink */
|
||||
--color-ink-100: #5c5362;
|
||||
--color-ink-200: #4f4754;
|
||||
--color-ink-300: #413b45;
|
||||
--color-ink-400: #353139;
|
||||
--color-ink-500: #312c34;
|
||||
--color-ink-600: #29252c;
|
||||
--color-ink-700: #232025;
|
||||
--color-ink-800: #19161a;
|
||||
--color-ink-900: #151317;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
|
||||
@@ -41,10 +41,6 @@
|
||||
--color-sand-300: #888682;
|
||||
--color-sand-400: #eed7ac;
|
||||
|
||||
--color-slate-100: #9c9eab;
|
||||
--color-slate-200: #9fa2bd;
|
||||
--color-slate-300: #5b5e7d;
|
||||
|
||||
--color-azure-300: #78bae9;
|
||||
--color-azure-400: #31b9f4;
|
||||
--color-azure-600: #0b8ce9;
|
||||
@@ -53,7 +49,6 @@
|
||||
|
||||
--color-jade-400: #47e469;
|
||||
--color-jade-600: #00cd72;
|
||||
--color-graphite-400: #9c9eab;
|
||||
|
||||
--color-gold-400: #fcbf64;
|
||||
--color-gold-500: #fdab34;
|
||||
@@ -208,7 +203,7 @@
|
||||
--node-component-slot-dot-outline-opacity: 5%;
|
||||
--node-component-slot-dot-outline: var(--color-black);
|
||||
--node-component-slot-text: var(--color-ash-800);
|
||||
--node-component-surface-highlight: var(--color-ash-500);
|
||||
--node-component-surface-highlight: var(--color-smoke-800);
|
||||
--node-component-surface-hovered: var(--color-smoke-200);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-white);
|
||||
@@ -227,7 +222,7 @@
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-azure-600);
|
||||
|
||||
--text-secondary: var(--color-ash-500);
|
||||
--text-secondary: var(--color-smoke-800);
|
||||
--text-primary: var(--color-charcoal-700);
|
||||
--input-surface: rgb(0 0 0 / 0.15);
|
||||
|
||||
@@ -264,7 +259,7 @@
|
||||
--secondary-background-selected
|
||||
);
|
||||
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
|
||||
--component-node-widget-background-highlighted: var(--color-ash-500);
|
||||
--component-node-widget-background-highlighted: var(--color-smoke-800);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-400);
|
||||
|
||||
@@ -344,19 +339,19 @@
|
||||
--node-component-border-error: var(--color-danger-100);
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-slate-300);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
--node-component-slot-dot-outline: var(--color-white);
|
||||
--node-component-slot-text: var(--color-slate-200);
|
||||
--node-component-surface-highlight: var(--color-slate-100);
|
||||
--node-component-slot-text: var(--color-smoke-700);
|
||||
--node-component-surface-highlight: var(--color-smoke-800);
|
||||
--node-component-surface-hovered: var(--color-charcoal-600);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-charcoal-600);
|
||||
--node-component-tooltip: var(--color-white);
|
||||
--node-component-tooltip-border: var(--color-slate-300);
|
||||
--node-component-tooltip-border: var(--color-charcoal-200);
|
||||
--node-component-tooltip-surface: var(--color-charcoal-800);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-800);
|
||||
--node-component-disabled: var(--color-alpha-charcoal-600-30);
|
||||
@@ -374,7 +369,7 @@
|
||||
);
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-secondary: var(--color-smoke-700);
|
||||
--text-primary: var(--color-white);
|
||||
|
||||
--input-surface: rgb(130 130 130 / 0.1);
|
||||
@@ -414,7 +409,7 @@
|
||||
--component-node-widget-background-disabled: var(
|
||||
--color-alpha-charcoal-600-30
|
||||
);
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
--component-node-widget-background-highlighted: var(--color-smoke-800);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-600);
|
||||
|
||||
|
||||
64
packages/registry-types/src/comfyRegistryTypes.ts
generated
64
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -4062,6 +4062,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/seedance/virtual-library/assets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["seedanceVirtualLibraryCreateAsset"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/seedance/complete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -14490,6 +14506,16 @@ export interface components {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
SeedanceVirtualLibraryCreateAssetRequest: {
|
||||
/** @description Publicly accessible URL of the image asset to upload to the caller's virtual portrait library. */
|
||||
url: string;
|
||||
/** @description Client-supplied content hash used as the per-customer dedup key. Re-submitting the same hash returns the existing asset id without re-uploading to BytePlus. */
|
||||
hash: string;
|
||||
};
|
||||
SeedanceVirtualLibraryCreateAssetResponse: {
|
||||
/** @description BytePlus-issued asset id. Clients poll seedanceGetAsset with this until status == Active. */
|
||||
asset_id: string;
|
||||
};
|
||||
WanVideoGenerationRequest: {
|
||||
/**
|
||||
* @description The ID of the model to call
|
||||
@@ -30338,10 +30364,7 @@ export interface operations {
|
||||
};
|
||||
seedanceGetAsset: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description BytePlus project name. Defaults to "default" if omitted. Must match the ProjectName used at create time. */
|
||||
project_name?: string;
|
||||
};
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description BytePlus-issued asset id returned by seedanceCreateAsset */
|
||||
@@ -30371,6 +30394,39 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceVirtualLibraryCreateAsset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Asset creation accepted (asynchronous — poll seedanceGetAsset) */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Error 4xx/5xx */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceVisualValidateCallback: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -8,7 +8,10 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
|
||||
workers: 1,
|
||||
use: {
|
||||
trace: 'on',
|
||||
video: 'on'
|
||||
video: 'on',
|
||||
launchOptions: {
|
||||
slowMo: Number(process.env.SLOW_MO) || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -162,6 +162,9 @@ catalogs:
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -360,6 +363,9 @@ catalogs:
|
||||
unplugin-vue-components:
|
||||
specifier: ^30.0.0
|
||||
version: 30.0.0
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
vite-plugin-dts:
|
||||
specifier: ^4.5.4
|
||||
version: 4.5.4
|
||||
@@ -497,6 +503,9 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2
|
||||
'@vee-validate/zod':
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -587,6 +596,9 @@ importers:
|
||||
typegpu:
|
||||
specifier: 'catalog:'
|
||||
version: 0.8.2
|
||||
vee-validate:
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -949,6 +961,9 @@ importers:
|
||||
lenis:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
|
||||
posthog-js:
|
||||
specifier: 'catalog:'
|
||||
version: 1.358.1
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4721,6 +4736,11 @@ packages:
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/analytics@2.0.1':
|
||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
||||
peerDependencies:
|
||||
@@ -9593,6 +9613,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -14038,6 +14063,14 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
@@ -14156,7 +14189,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20051,6 +20084,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -55,6 +55,7 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
@@ -121,6 +122,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||
<div
|
||||
class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1"
|
||||
data-testid="bounding-box"
|
||||
>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import EditableText from './EditableText.vue'
|
||||
@@ -17,10 +15,6 @@ describe('EditableText', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(EditableText, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputText }
|
||||
},
|
||||
props: {
|
||||
...props,
|
||||
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
<template>
|
||||
<div class="editable-text">
|
||||
<div class="editable-text inline">
|
||||
<component :is="labelType" v-if="!isEditing" :class="labelClass">
|
||||
{{ modelValue }}
|
||||
</component>
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
<Input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model:model-value="inputValue"
|
||||
v-model="inputValue"
|
||||
v-focus
|
||||
type="text"
|
||||
size="small"
|
||||
fluid
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: finishEditing,
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keydown.enter.capture.stop="blurInputElement"
|
||||
class="h-full rounded-none p-0 focus-visible:ring-0"
|
||||
v-bind="inputAttrs"
|
||||
@blur="finishEditing"
|
||||
@keydown.enter.capture.stop="inputRef?.blur()"
|
||||
@keydown.escape.capture.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@@ -29,9 +23,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
@@ -48,30 +43,23 @@ const {
|
||||
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const inputRef = ref<InstanceType<typeof Input>>()
|
||||
const isCanceling = ref(false)
|
||||
|
||||
const blurInputElement = () => {
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
inputRef.value?.$el.blur()
|
||||
}
|
||||
const finishEditing = () => {
|
||||
// Don't save if we're canceling
|
||||
function finishEditing() {
|
||||
if (!isCanceling.value) {
|
||||
emit('edit', inputValue.value)
|
||||
}
|
||||
isCanceling.value = false
|
||||
}
|
||||
const cancelEditing = () => {
|
||||
// Set canceling flag to prevent blur from saving
|
||||
|
||||
function cancelEditing() {
|
||||
isCanceling.value = true
|
||||
// Reset to original value
|
||||
inputValue.value = modelValue
|
||||
// Emit cancel event
|
||||
emit('cancel')
|
||||
// Blur the input to exit edit mode
|
||||
blurInputElement()
|
||||
inputRef.value?.blur()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isEditing,
|
||||
async (newVal) => {
|
||||
@@ -82,27 +70,14 @@ watch(
|
||||
const fileName = inputValue.value.includes('.')
|
||||
? inputValue.value.split('.').slice(0, -1).join('.')
|
||||
: inputValue.value
|
||||
const start = 0
|
||||
const end = fileName.length
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
const inputElement = inputRef.value.$el
|
||||
inputElement.setSelectionRange?.(start, end)
|
||||
inputRef.value.setSelectionRange(0, fileName.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const vFocus = {
|
||||
mounted: (el: HTMLElement) => el.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editable-text {
|
||||
display: inline;
|
||||
}
|
||||
.editable-text input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
85
src/components/dialog/content/PromptDialogContent.test.ts
Normal file
85
src/components/dialog/content/PromptDialogContent.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import PromptDialogContent from './PromptDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof PromptDialogContent>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
describe('PromptDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function renderComponent(props: Partial<Props> = {}) {
|
||||
const user = userEvent.setup()
|
||||
render(PromptDialogContent, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
message: 'Enter a name',
|
||||
defaultValue: '',
|
||||
onConfirm: vi.fn(),
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('pre-fills the input with defaultValue', () => {
|
||||
renderComponent({ defaultValue: 'my workflow' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('my workflow')
|
||||
})
|
||||
|
||||
it('calls onConfirm and closes dialog when Confirm is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'renamed')
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('renamed')
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onConfirm when Enter is pressed inside the input', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ defaultValue: 'original', onConfirm })
|
||||
|
||||
await user.clear(screen.getByRole('textbox'))
|
||||
await user.type(screen.getByRole('textbox'), 'via enter')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('via enter')
|
||||
})
|
||||
|
||||
it('closes dialog when Confirm button is clicked', async () => {
|
||||
const { user } = renderComponent({ defaultValue: '' })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }))
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('selects all text when the input is focused', async () => {
|
||||
renderComponent({ defaultValue: 'pre-filled text', onConfirm: vi.fn() })
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
const spy = vi.spyOn(input, 'setSelectionRange')
|
||||
await fireEvent.focus(input)
|
||||
expect(spy).toHaveBeenCalledWith(0, 'pre-filled text'.length)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,43 @@
|
||||
<template>
|
||||
<div class="prompt-dialog-content flex flex-col gap-2 pt-8">
|
||||
<FloatLabel>
|
||||
<InputText
|
||||
<label class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{{ message }}
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
:placeholder
|
||||
autofocus
|
||||
@keyup.enter="onConfirm"
|
||||
@focus="selectAllText"
|
||||
@keyup.enter="handleConfirm"
|
||||
@focus="inputRef?.selectAll()"
|
||||
/>
|
||||
<label>{{ message }}</label>
|
||||
</FloatLabel>
|
||||
<Button @click="onConfirm">
|
||||
</label>
|
||||
<Button @click="handleConfirm">
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
const { message, defaultValue, onConfirm, placeholder } = defineProps<{
|
||||
message: string
|
||||
defaultValue: string
|
||||
onConfirm: (value: string) => void
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const inputValue = ref<string>(props.defaultValue)
|
||||
const inputValue = ref<string>(defaultValue)
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(inputValue.value)
|
||||
function handleConfirm() {
|
||||
onConfirm(inputValue.value)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const selectAllText = () => {
|
||||
if (!inputRef.value) return
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
const inputElement = inputRef.value.$el
|
||||
inputElement.setSelectionRange(0, inputElement.value.length)
|
||||
}
|
||||
const inputRef = ref<InstanceType<typeof Input>>()
|
||||
</script>
|
||||
|
||||
110
src/components/dialog/content/signin/SignUpForm.test.ts
Normal file
110
src/components/dialog/content/signin/SignUpForm.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import SignUpForm from './SignUpForm.vue'
|
||||
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
const mockLoadingRef = ref(false)
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
get loading() {
|
||||
return mockLoadingRef.value
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(SignUpForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Password manager autofill attributes', () => {
|
||||
it('renders email input with attributes Chrome needs to recognize the field', () => {
|
||||
renderComponent()
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.signup.emailPlaceholder
|
||||
)
|
||||
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email')
|
||||
expect(emailInput).toHaveAttribute('name', 'email')
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(emailInput).toHaveAttribute('type', 'email')
|
||||
})
|
||||
|
||||
it('renders password input with new-password autofill attributes', () => {
|
||||
renderComponent()
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.signup.passwordPlaceholder
|
||||
)
|
||||
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password')
|
||||
expect(passwordInput).toHaveAttribute('name', 'password')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
|
||||
})
|
||||
|
||||
it('renders confirm-password input with distinct name and new-password autocomplete', () => {
|
||||
renderComponent()
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
enMessages.auth.login.confirmPasswordPlaceholder
|
||||
)
|
||||
expect(confirmPasswordInput).toHaveAttribute(
|
||||
'id',
|
||||
'comfy-org-sign-up-confirm-password'
|
||||
)
|
||||
expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword')
|
||||
expect(confirmPasswordInput).toHaveAttribute(
|
||||
'autocomplete',
|
||||
'new-password'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,9 +15,10 @@
|
||||
</label>
|
||||
<InputText
|
||||
pt:root:id="comfy-org-sign-up-email"
|
||||
pt:root:name="email"
|
||||
pt:root:autocomplete="email"
|
||||
class="h-10"
|
||||
type="text"
|
||||
type="email"
|
||||
:placeholder="t('auth.signup.emailPlaceholder')"
|
||||
:invalid="$field.invalid"
|
||||
/>
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
|
||||
>
|
||||
<!-- Native drag area for Electron -->
|
||||
<div
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
@@ -189,7 +184,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
@@ -140,11 +140,11 @@ const iconClass = computed(() => {
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'animate-spin text-slate-100'
|
||||
return 'animate-spin text-text-secondary'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'text-danger-200'
|
||||
}
|
||||
return 'text-slate-100'
|
||||
return 'text-text-secondary'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
ref="sidebarTabRef"
|
||||
:title="title"
|
||||
v-bind="$attrs"
|
||||
:data-testid="dataTestid"
|
||||
:class="
|
||||
cn(
|
||||
'workflows-sidebar-tab',
|
||||
isOverDropZone && 'bg-primary-500/10 ring-4 ring-primary-500 ring-inset'
|
||||
)
|
||||
"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #alt-title>
|
||||
<slot name="alt-title" />
|
||||
@@ -146,10 +140,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { unrefElement, useDropZone } from '@vueuse/core'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -170,8 +162,6 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { getDataFromJSON } from '@/scripts/metadata/json'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import {
|
||||
@@ -199,7 +189,6 @@ const settingStore = useSettingStore()
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
const sidebarTabRef = useTemplateRef('sidebarTabRef')
|
||||
|
||||
const searchBoxRef = ref()
|
||||
|
||||
@@ -360,34 +349,4 @@ onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
await workflowBookmarkStore.loadBookmarks()
|
||||
})
|
||||
|
||||
const sidebarTabGetter = () => {
|
||||
const el = unrefElement(sidebarTabRef)
|
||||
return el instanceof HTMLElement ? el : undefined
|
||||
}
|
||||
|
||||
const { isOverDropZone } = useDropZone(sidebarTabGetter, {
|
||||
onDrop: async (files) => {
|
||||
if (!files?.length) return
|
||||
await Promise.allSettled(
|
||||
files.map(async (file) => {
|
||||
const { workflow } = (await getDataFromJSON(file)) ?? {}
|
||||
if (!workflow) return
|
||||
const workflowJSON = await validateComfyWorkflow(workflow)
|
||||
if (!workflowJSON) return
|
||||
|
||||
const comfyWorkflow = workflowStore.createNewTemporary(
|
||||
file.name,
|
||||
workflowJSON
|
||||
)
|
||||
await workflowStore.closeWorkflow(comfyWorkflow)
|
||||
await comfyWorkflow.save()
|
||||
})
|
||||
)
|
||||
await workflowStore.syncWorkflows()
|
||||
},
|
||||
dataTypes: ['application/json'],
|
||||
multiple: true,
|
||||
preventDefaultForUnhandled: false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -209,7 +209,7 @@ const dotClasses = computed(() => {
|
||||
return 'bg-gold-600'
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-slate-100'
|
||||
return 'bg-text-secondary'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
select: () => inputRef.value?.select()
|
||||
select: () => inputRef.value?.select(),
|
||||
blur: () => inputRef.value?.blur(),
|
||||
setSelectionRange: (start: number, end: number) =>
|
||||
inputRef.value?.setSelectionRange(start, end),
|
||||
selectAll: () =>
|
||||
inputRef.value?.setSelectionRange(0, inputRef.value.value.length)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
89
src/components/ui/search-input/SearchAutocomplete.test.ts
Normal file
89
src/components/ui/search-input/SearchAutocomplete.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SearchAutocomplete from './SearchAutocomplete.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
|
||||
})
|
||||
|
||||
describe('SearchAutocomplete', () => {
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
return render(SearchAutocomplete, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ComboboxRoot: { template: '<div><slot /></div>' },
|
||||
ComboboxAnchor: { template: '<div><slot /></div>' },
|
||||
ComboboxInput: {
|
||||
template:
|
||||
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue']
|
||||
},
|
||||
ComboboxPortal: { template: '<div><slot /></div>' },
|
||||
ComboboxContent: { template: '<div><slot /></div>' },
|
||||
ComboboxItem: {
|
||||
template:
|
||||
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
|
||||
emits: ['select']
|
||||
}
|
||||
}
|
||||
},
|
||||
props: { modelValue: '', ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('suggestions dropdown', () => {
|
||||
it('does not render items when suggestions list is empty', () => {
|
||||
renderComponent({ suggestions: [] })
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a button for each suggestion', () => {
|
||||
renderComponent({ suggestions: ['foo', 'bar'] })
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits select with the suggestion when an item is clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onSelect).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
|
||||
it('updates modelValue to the suggestion label on selection', async () => {
|
||||
const onUpdateModelValue = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderComponent({
|
||||
suggestions: ['foo', 'bar'],
|
||||
'onUpdate:modelValue': onUpdateModelValue
|
||||
})
|
||||
await user.click(screen.getByText('foo'))
|
||||
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with optionLabel', () => {
|
||||
it('displays the optionLabel property as the suggestion text', () => {
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query' })
|
||||
expect(screen.getByText('my-extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits the full item object on selection when optionLabel is set', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const suggestions = [{ id: 1, query: 'my-extension' }]
|
||||
renderComponent({ suggestions, optionLabel: 'query', onSelect })
|
||||
await user.click(screen.getByText('my-extension'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -65,34 +65,36 @@
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +107,7 @@ import {
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restAttrs } = defineProps<{
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
|
||||
const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
|
||||
|
||||
defineExpose({
|
||||
focus: () => textareaEl.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-bind="restAttrs"
|
||||
ref="textareaEl"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile && !showLeftPanel"
|
||||
size="lg"
|
||||
|
||||
142
src/composables/maskeditor/useBrushAdjustment.test.ts
Normal file
142
src/composables/maskeditor/useBrushAdjustment.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({
|
||||
useCoordinateTransform: () => ({
|
||||
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { registerExtension: vi.fn() }
|
||||
}))
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useBrushAdjustment } from './useBrushAdjustment'
|
||||
|
||||
function makePointerEvent(offsetX: number, offsetY: number): PointerEvent {
|
||||
return {
|
||||
offsetX,
|
||||
offsetY,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('startBrushAdjustment', () => {
|
||||
it('sets brushPreviewGradientVisible to true', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushPreviewGradientVisible = false
|
||||
const { startBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
expect(store.brushPreviewGradientVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBrushAdjustment', () => {
|
||||
it('does nothing when startBrushAdjustment has not been called', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const hardnessBefore = store.brushSettings.hardness
|
||||
const { handleBrushAdjustment } = useBrushAdjustment()
|
||||
await handleBrushAdjustment(makePointerEvent(200, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBe(hardnessBefore)
|
||||
})
|
||||
|
||||
it('does not change size when deltaX is within the dead zone', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(103, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
})
|
||||
|
||||
it('increases size when dragging right past the dead zone', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
|
||||
})
|
||||
|
||||
it('does not compound size when pointer stays at the same position', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(100, 100))
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
const sizeAfterFirstMove = store.brushSettings.size
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(150, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeAfterFirstMove)
|
||||
})
|
||||
|
||||
it('continues increasing size beyond 100px drag (no delta saturation)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(100, 0))
|
||||
const sizeAt100px = store.brushSettings.size
|
||||
|
||||
await handleBrushAdjustment(makePointerEvent(300, 0))
|
||||
const sizeAt300px = store.brushSettings.size
|
||||
|
||||
expect(sizeAt300px).toBeGreaterThan(sizeAt100px)
|
||||
})
|
||||
|
||||
it('clamps size to minimum 1 when dragging far left', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.size = 2
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment()
|
||||
await startBrushAdjustment(makePointerEvent(500, 100))
|
||||
await handleBrushAdjustment(makePointerEvent(0, 100))
|
||||
expect(store.brushSettings.size).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps hardness to maximum 1 when dragging far up', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.9
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
brushAdjustmentSpeed: 10
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(100, 500))
|
||||
await handleBrushAdjustment(makePointerEvent(100, 0))
|
||||
expect(store.brushSettings.hardness).toBe(1)
|
||||
})
|
||||
|
||||
it('suppresses hardness change when X delta dominates (useDominantAxis=true)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.5
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
useDominantAxis: true
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
await handleBrushAdjustment(makePointerEvent(100, 10))
|
||||
expect(store.brushSettings.size).toBeGreaterThan(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBe(0.5)
|
||||
})
|
||||
|
||||
it('suppresses size change when Y delta dominates (useDominantAxis=true)', async () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.hardness = 0.5
|
||||
const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({
|
||||
useDominantAxis: true
|
||||
})
|
||||
await startBrushAdjustment(makePointerEvent(0, 0))
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const hardnessBefore = store.brushSettings.hardness
|
||||
await handleBrushAdjustment(makePointerEvent(10, 100))
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
expect(store.brushSettings.hardness).toBeLessThan(hardnessBefore)
|
||||
})
|
||||
})
|
||||
83
src/composables/maskeditor/useBrushAdjustment.ts
Normal file
83
src/composables/maskeditor/useBrushAdjustment.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
|
||||
export function useBrushAdjustment(initialSettings?: {
|
||||
useDominantAxis?: boolean
|
||||
brushAdjustmentSpeed?: number
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const initialBrushSize = ref(0)
|
||||
const initialBrushHardness = ref(0)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
initialBrushSize.value = store.brushSettings.size
|
||||
initialBrushHardness.value = store.brushSettings.hardness
|
||||
}
|
||||
|
||||
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
500,
|
||||
initialBrushSize.value + (finalDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
initialBrushHardness.value -
|
||||
(finalDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
return { startBrushAdjustment, handleBrushAdjustment }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { tgpu } from 'typegpu'
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { StrokeProcessor } from './StrokeProcessor'
|
||||
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
|
||||
import { useBrushAdjustment } from './useBrushAdjustment'
|
||||
import {
|
||||
resetDirtyRect,
|
||||
updateDirtyRect,
|
||||
@@ -30,6 +31,8 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const persistence = useBrushPersistence()
|
||||
const { startBrushAdjustment, handleBrushAdjustment } =
|
||||
useBrushAdjustment(initialSettings)
|
||||
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
@@ -63,10 +66,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
// Stroke processor instance
|
||||
let strokeProcessor: StrokeProcessor | null = null
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
persistence.loadAndApply()
|
||||
|
||||
// Handle external clear events
|
||||
@@ -753,78 +752,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the brush adjustment interaction.
|
||||
* @param event - The pointer event.
|
||||
*/
|
||||
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the brush adjustment movement.
|
||||
* @param event - The pointer event.
|
||||
*/
|
||||
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
|
||||
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
500,
|
||||
store.brushSettings.size +
|
||||
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
store.brushSettings.hardness -
|
||||
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back the GPU textures to CPU ImageDatas.
|
||||
* @returns Object containing mask and rgb ImageDatas.
|
||||
|
||||
70
src/composables/node/useNodeAnimatedImage.test.ts
Normal file
70
src/composables/node/useNodeAnimatedImage.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock } = vi.hoisted(() => ({
|
||||
canvasInteractionsMock: {
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn(),
|
||||
forwardEventToCanvas: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: () => canvasInteractionsMock
|
||||
}))
|
||||
// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.)
|
||||
// that we cannot pull in here, so we stub only the constant we need.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview'
|
||||
}))
|
||||
|
||||
describe('useNodeAnimatedImage', () => {
|
||||
function setup() {
|
||||
vi.clearAllMocks()
|
||||
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
|
||||
const { showAnimatedPreview, removeAnimatedPreview } =
|
||||
useNodeAnimatedImage()
|
||||
showAnimatedPreview(node)
|
||||
const element = node.widgets[0].element
|
||||
document.body.append(element)
|
||||
onTestFinished(() => element.remove())
|
||||
return { node, element, showAnimatedPreview, removeAnimatedPreview }
|
||||
}
|
||||
|
||||
it('forwards non-right-click pointer events and wheel to the canvas while alive', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new WheelEvent('wheel'))
|
||||
element.dispatchEvent(new PointerEvent('pointermove'))
|
||||
element.dispatchEvent(new PointerEvent('pointerup'))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3)
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => {
|
||||
const { element } = setup()
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1)
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detaches every listener when the preview is removed', () => {
|
||||
const { node, element, removeAnimatedPreview } = setup()
|
||||
removeAnimatedPreview(node)
|
||||
|
||||
element.dispatchEvent(new WheelEvent('wheel'))
|
||||
element.dispatchEvent(new PointerEvent('pointermove'))
|
||||
element.dispatchEvent(new PointerEvent('pointerup'))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 }))
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 }))
|
||||
|
||||
expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
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'
|
||||
@@ -39,17 +40,20 @@ export function useNodeAnimatedImage() {
|
||||
const { handleWheel, handlePointer, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
node.imgs[0].style.pointerEvents = 'none'
|
||||
element.addEventListener('wheel', handleWheel)
|
||||
element.addEventListener('pointermove', handlePointer)
|
||||
element.addEventListener('pointerup', handlePointer)
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
element.addEventListener('wheel', handleWheel, { signal })
|
||||
element.addEventListener('pointermove', handlePointer, { signal })
|
||||
element.addEventListener('pointerup', handlePointer, { signal })
|
||||
element.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => {
|
||||
return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)
|
||||
},
|
||||
true
|
||||
(e) => (e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)),
|
||||
{ capture: true, signal }
|
||||
)
|
||||
|
||||
widget.onRemove = useChainCallback(widget.onRemove, () => {
|
||||
controller.abort()
|
||||
})
|
||||
widget.serialize = false
|
||||
widget.serializeValue = () => undefined
|
||||
}
|
||||
|
||||
93
src/composables/node/useNodeImage.test.ts
Normal file
93
src/composables/node/useNodeImage.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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,3 +1,4 @@
|
||||
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'
|
||||
@@ -151,11 +152,6 @@ 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)
|
||||
@@ -176,6 +172,16 @@ 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
|
||||
@@ -14,11 +17,29 @@ vi.mock('primevue/usetoast', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
function setupComposable(): ReturnType<typeof useReconnectingNotification> {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
reconnecting: 'Reconnecting',
|
||||
reconnected: 'Reconnected'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
let result!: ReturnType<typeof useReconnectingNotification>
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
result = useReconnectingNotification()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
return result
|
||||
}
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
disableToast: false
|
||||
@@ -47,7 +68,7 @@ describe('useReconnectingNotification', () => {
|
||||
})
|
||||
|
||||
it('does not show toast immediately on reconnecting', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
const { onReconnecting } = setupComposable()
|
||||
|
||||
onReconnecting()
|
||||
|
||||
@@ -55,7 +76,7 @@ describe('useReconnectingNotification', () => {
|
||||
})
|
||||
|
||||
it('shows error toast after delay', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
const { onReconnecting } = setupComposable()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
@@ -63,13 +84,13 @@ describe('useReconnectingNotification', () => {
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
summary: 'Reconnecting'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses toast when reconnected before delay expires', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
const { onReconnecting, onReconnected } = setupComposable()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(500)
|
||||
@@ -81,7 +102,7 @@ describe('useReconnectingNotification', () => {
|
||||
})
|
||||
|
||||
it('removes toast and shows success when reconnected after delay', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
const { onReconnecting, onReconnected } = setupComposable()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
@@ -92,13 +113,13 @@ describe('useReconnectingNotification', () => {
|
||||
expect(mockToastRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
summary: 'Reconnecting'
|
||||
})
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'g.reconnected',
|
||||
summary: 'Reconnected',
|
||||
life: 2000
|
||||
})
|
||||
)
|
||||
@@ -106,7 +127,7 @@ describe('useReconnectingNotification', () => {
|
||||
|
||||
it('does nothing when toast is disabled via setting', () => {
|
||||
settingMocks.disableToast = true
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
const { onReconnecting, onReconnected } = setupComposable()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
@@ -117,7 +138,7 @@ describe('useReconnectingNotification', () => {
|
||||
})
|
||||
|
||||
it('does nothing when onReconnected is called without prior onReconnecting', () => {
|
||||
const { onReconnected } = useReconnectingNotification()
|
||||
const { onReconnected } = setupComposable()
|
||||
|
||||
onReconnected()
|
||||
|
||||
@@ -126,7 +147,7 @@ describe('useReconnectingNotification', () => {
|
||||
})
|
||||
|
||||
it('handles multiple reconnecting events without duplicating toasts', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
const { onReconnecting } = setupComposable()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500) // first toast fires
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
/** Whether the resolved source widget is workflow-persistent. */
|
||||
readonly sourceSerialize: boolean
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
@@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
const promotedSourceWriteMetaByGraph = new WeakMap<
|
||||
LGraph,
|
||||
Map<string, PromotedSourceWriteMeta>
|
||||
>()
|
||||
|
||||
interface PromotedSourceWriteMeta {
|
||||
value: IBaseWidget['value']
|
||||
writerInstanceId: string
|
||||
}
|
||||
|
||||
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
|
||||
value: TValue
|
||||
): TValue {
|
||||
return value != null && typeof value === 'object'
|
||||
? (JSON.parse(JSON.stringify(value)) as TValue)
|
||||
: value
|
||||
}
|
||||
|
||||
function getPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string
|
||||
): PromotedSourceWriteMeta | undefined {
|
||||
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
|
||||
}
|
||||
|
||||
function setPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string,
|
||||
meta: PromotedSourceWriteMeta
|
||||
): void {
|
||||
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
|
||||
if (!metaBySource) {
|
||||
metaBySource = new Map<string, PromotedSourceWriteMeta>()
|
||||
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
|
||||
}
|
||||
metaBySource.set(sourceKey, meta)
|
||||
}
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
@@ -117,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
/**
|
||||
* Whether the resolved source widget is workflow-persistent.
|
||||
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
|
||||
* whose source sets serialize = false.
|
||||
*/
|
||||
get sourceSerialize(): boolean {
|
||||
return this.resolveDeepest()?.widget.serialize !== false
|
||||
}
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
@@ -198,52 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
private get _instanceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
private get _sharedSourceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution-time serialization — returns the per-instance value stored
|
||||
* during configure, falling back to the regular value getter.
|
||||
*
|
||||
* The widget state store is shared across instances (keyed by inner node
|
||||
* ID), so the regular getter returns the last-configured value for all
|
||||
* instances. graphToPrompt already prefers serializeValue over .value,
|
||||
* so this is the hook that makes multi-instance execution correct.
|
||||
*/
|
||||
serializeValue(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.captureSiblingFallbackValues()
|
||||
|
||||
// Keep per-instance map in sync for execution (graphToPrompt)
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(value)
|
||||
)
|
||||
setPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey,
|
||||
{
|
||||
value: cloneWidgetValue(value),
|
||||
writerInstanceId: String(this.subgraphNode.id)
|
||||
}
|
||||
)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
@@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getTrackedValue(): IBaseWidget['value'] {
|
||||
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
|
||||
this._instanceKey
|
||||
)
|
||||
const sharedValue = this.getSharedValue()
|
||||
|
||||
if (instanceValue === undefined) return sharedValue
|
||||
|
||||
const sourceWriteMeta = getPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey
|
||||
)
|
||||
if (
|
||||
sharedValue !== undefined &&
|
||||
sourceWriteMeta &&
|
||||
!isEqual(sharedValue, sourceWriteMeta.value)
|
||||
) {
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(sharedValue)
|
||||
)
|
||||
return sharedValue
|
||||
}
|
||||
|
||||
return instanceValue as IBaseWidget['value']
|
||||
}
|
||||
|
||||
private getSharedValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
@@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private captureSiblingFallbackValues(): void {
|
||||
const { rootGraph } = this.subgraphNode
|
||||
|
||||
for (const node of rootGraph.nodes) {
|
||||
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
|
||||
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
|
||||
if (node._instanceWidgetValues.has(this._instanceKey)) continue
|
||||
|
||||
const siblingView = node.widgets.find(
|
||||
(widget): widget is IPromotedWidgetView =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId === this.sourceNodeId &&
|
||||
widget.sourceWidgetName === this.sourceWidgetName &&
|
||||
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!siblingView) continue
|
||||
|
||||
node._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(siblingView.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
|
||||
@@ -497,7 +497,8 @@ useExtensionService().registerExtension({
|
||||
const settings = {
|
||||
loadFolder: 'output',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState
|
||||
cameraState: cameraState,
|
||||
silentOnNotFound: true
|
||||
}
|
||||
|
||||
config.configure(settings)
|
||||
@@ -528,7 +529,8 @@ useExtensionService().registerExtension({
|
||||
loadFolder: 'output',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState,
|
||||
bgImagePath: bgImagePath
|
||||
bgImagePath: bgImagePath,
|
||||
silentOnNotFound: true
|
||||
}
|
||||
|
||||
config.configure(settings)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
GizmoConfig,
|
||||
ModelConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
@@ -162,3 +164,88 @@ describe('Load3DConfiguration.loadModelConfig', () => {
|
||||
expect(result.gizmo).toEqual(fullGizmo)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.silentOnNotFound propagation', () => {
|
||||
let loadModelSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
function makeLoad3dMock(): Load3d {
|
||||
loadModelSpy = vi.fn().mockResolvedValue(undefined)
|
||||
return {
|
||||
loadModel: loadModelSpy,
|
||||
setUpDirection: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('configureForSaveMesh forwards silentOnNotFound: true to loadModel', async () => {
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
config.configureForSaveMesh('output', 'model.glb', {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
await flush()
|
||||
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
it('configureForSaveMesh uses silentOnNotFound: false when option is omitted', async () => {
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
config.configureForSaveMesh('output', 'model.glb')
|
||||
await flush()
|
||||
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
|
||||
silentOnNotFound: false
|
||||
})
|
||||
})
|
||||
|
||||
it('configure forwards silentOnNotFound: true from settings to loadModel', async () => {
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output',
|
||||
silentOnNotFound: true
|
||||
})
|
||||
await flush()
|
||||
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
it('configure uses silentOnNotFound: false when setting is omitted', async () => {
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
|
||||
silentOnNotFound: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ type Load3DConfigurationSettings = {
|
||||
width?: IBaseWidget
|
||||
height?: IBaseWidget
|
||||
bgImagePath?: string
|
||||
silentOnNotFound?: boolean
|
||||
}
|
||||
|
||||
class Load3DConfiguration {
|
||||
@@ -29,8 +30,16 @@ class Load3DConfiguration {
|
||||
private properties?: Dictionary<NodeProperty | undefined>
|
||||
) {}
|
||||
|
||||
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
|
||||
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
|
||||
configureForSaveMesh(
|
||||
loadFolder: 'input' | 'output',
|
||||
filePath: string,
|
||||
options?: { silentOnNotFound?: boolean }
|
||||
) {
|
||||
this.setupModelHandlingForSaveMesh(
|
||||
filePath,
|
||||
loadFolder,
|
||||
options?.silentOnNotFound ?? false
|
||||
)
|
||||
this.setupDefaultProperties()
|
||||
}
|
||||
|
||||
@@ -38,7 +47,8 @@ class Load3DConfiguration {
|
||||
this.setupModelHandling(
|
||||
setting.modelWidget,
|
||||
setting.loadFolder,
|
||||
setting.cameraState
|
||||
setting.cameraState,
|
||||
setting.silentOnNotFound ?? false
|
||||
)
|
||||
this.setupTargetSize(setting.width, setting.height)
|
||||
this.setupDefaultProperties(setting.bgImagePath)
|
||||
@@ -58,8 +68,16 @@ class Load3DConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
|
||||
private setupModelHandlingForSaveMesh(
|
||||
filePath: string,
|
||||
loadFolder: string,
|
||||
silentOnNotFound: boolean
|
||||
) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
||||
loadFolder,
|
||||
undefined,
|
||||
silentOnNotFound
|
||||
)
|
||||
|
||||
if (filePath) {
|
||||
onModelWidgetUpdate(filePath)
|
||||
@@ -69,11 +87,13 @@ class Load3DConfiguration {
|
||||
private setupModelHandling(
|
||||
modelWidget: IBaseWidget,
|
||||
loadFolder: string,
|
||||
cameraState?: CameraState
|
||||
cameraState?: CameraState,
|
||||
silentOnNotFound: boolean = false
|
||||
) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
||||
loadFolder,
|
||||
cameraState
|
||||
cameraState,
|
||||
silentOnNotFound
|
||||
)
|
||||
if (modelWidget.value) {
|
||||
onModelWidgetUpdate(modelWidget.value)
|
||||
@@ -241,7 +261,8 @@ class Load3DConfiguration {
|
||||
|
||||
private createModelUpdateHandler(
|
||||
loadFolder: string,
|
||||
cameraState?: CameraState
|
||||
cameraState?: CameraState,
|
||||
silentOnNotFound: boolean = false
|
||||
) {
|
||||
let isFirstLoad = true
|
||||
return async (value: string | number | boolean | object) => {
|
||||
@@ -258,7 +279,7 @@ class Load3DConfiguration {
|
||||
)
|
||||
)
|
||||
|
||||
await this.load3d.loadModel(modelUrl, filename)
|
||||
await this.load3d.loadModel(modelUrl, filename, { silentOnNotFound })
|
||||
|
||||
const modelConfig = this.loadModelConfig()
|
||||
this.applyModelConfig(modelConfig)
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
EventCallback,
|
||||
GizmoMode,
|
||||
Load3DOptions,
|
||||
LoadModelOptions,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
@@ -500,7 +501,11 @@ class Load3d {
|
||||
return this._loadGeneration
|
||||
}
|
||||
|
||||
async loadModel(url: string, originalFileName?: string): Promise<void> {
|
||||
async loadModel(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
this._loadGeneration += 1
|
||||
|
||||
if (this.loadingPromise) {
|
||||
@@ -509,7 +514,11 @@ class Load3d {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
this.loadingPromise = this._loadModelInternal(url, originalFileName)
|
||||
this.loadingPromise = this._loadModelInternal(
|
||||
url,
|
||||
originalFileName,
|
||||
options
|
||||
)
|
||||
return this.loadingPromise
|
||||
}
|
||||
|
||||
@@ -525,7 +534,8 @@ class Load3d {
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
@@ -533,7 +543,7 @@ class Load3d {
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
|
||||
await this.loaderManager.loadModel(url, originalFileName)
|
||||
await this.loaderManager.loadModel(url, originalFileName, options)
|
||||
|
||||
// Auto-detect and setup animations if present
|
||||
if (this.modelManager.currentModel) {
|
||||
|
||||
@@ -436,6 +436,55 @@ describe('LoaderManager', () => {
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses the alert on a 404 when silentOnNotFound is set', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
const notFound = new Error(
|
||||
'fetch for "..." responded with 404: Not Found'
|
||||
)
|
||||
meshLoad.mockRejectedValueOnce(notFound)
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb', undefined, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
expect(addAlert).not.toHaveBeenCalledWith(
|
||||
'toastMessages.errorLoadingModel'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects a 404 from the response status field on three.js HttpError', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
const httpError = Object.assign(new Error('not found'), {
|
||||
response: { status: 404 }
|
||||
})
|
||||
meshLoad.mockRejectedValueOnce(httpError)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb', undefined, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
expect(addAlert).not.toHaveBeenCalledWith(
|
||||
'toastMessages.errorLoadingModel'
|
||||
)
|
||||
})
|
||||
|
||||
it('still alerts on non-404 errors when silentOnNotFound is set', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockRejectedValueOnce(new Error('parse failure: bad header'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb', undefined, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
|
||||
})
|
||||
|
||||
it('discards the result of a stale load when a newer one has started', async () => {
|
||||
const { lm, modelManager, eventManager } = makeLoaderManager()
|
||||
|
||||
|
||||
@@ -10,10 +10,24 @@ import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
LoadModelOptions,
|
||||
LoaderManagerInterface,
|
||||
ModelManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
/**
|
||||
* three.js's HttpError attaches the failed `Response` to the thrown Error.
|
||||
* fetchModelData throws a plain Error whose message embeds the status code.
|
||||
* Detect both forms so we can keep the toast for parse / network failures
|
||||
* but stay silent on 404 when the caller opted in.
|
||||
*/
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const withResponse = error as Error & { response?: { status?: number } }
|
||||
if (withResponse.response?.status === 404) return true
|
||||
return /\b404\b/.test(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
|
||||
* file extensions it owns; LoaderManager picks one by extension.
|
||||
@@ -53,7 +67,11 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
dispose(): void {}
|
||||
|
||||
async loadModel(url: string, originalFileName?: string): Promise<void> {
|
||||
async loadModel(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
const loadId = ++this.currentLoadId
|
||||
|
||||
try {
|
||||
@@ -105,7 +123,9 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
if (loadId === this.currentLoadId) {
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
if (!(options?.silentOnNotFound && isNotFoundError(error))) {
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +198,23 @@ export interface ModelManagerInterface {
|
||||
setupModelMaterials(model: THREE.Object3D): void
|
||||
}
|
||||
|
||||
export interface LoadModelOptions {
|
||||
/**
|
||||
* When true, suppress the user-facing toast for file-not-found
|
||||
* (HTTP 404) errors. Other errors (parse failures, network drops)
|
||||
* still surface a toast. Use for "preview" surfaces whose model
|
||||
* file is server-produced and may legitimately be absent locally
|
||||
* (e.g. shared workflows on a fresh machine).
|
||||
*/
|
||||
silentOnNotFound?: boolean
|
||||
}
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
init(): void
|
||||
dispose(): void
|
||||
loadModel(url: string, originalFileName?: string): Promise<void>
|
||||
loadModel(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
@@ -103,7 +103,9 @@ useExtensionService().registerExtension({
|
||||
|
||||
const loadFolder = fileInfo.type as 'input' | 'output'
|
||||
|
||||
config.configureForSaveMesh(loadFolder, filePath)
|
||||
config.configureForSaveMesh(loadFolder, filePath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
|
||||
240
src/lib/litegraph/src/LGraphCanvas.ghost.test.ts
Normal file
240
src/lib/litegraph/src/LGraphCanvas.ghost.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
setActor: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function createGhostTestHarness() {
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
document.body.appendChild(canvasElement)
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {}
|
||||
})
|
||||
|
||||
const graph = new LGraph()
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true,
|
||||
skip_events: true
|
||||
})
|
||||
|
||||
const node = new LGraphNode('test')
|
||||
node.size = [200, 100]
|
||||
graph.add(node)
|
||||
|
||||
return { canvas, canvasElement, graph, node }
|
||||
}
|
||||
|
||||
describe('LGraphCanvas ghost placement auto-pan', () => {
|
||||
let canvas: LGraphCanvas
|
||||
let canvasElement: HTMLCanvasElement
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
;({ canvas, canvasElement, node } = createGhostTestHarness())
|
||||
// Near left edge so autopan fires by default
|
||||
canvas.mouse[0] = 5
|
||||
canvas.mouse[1] = 300
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (canvas.state.ghostNodeId != null) canvas.finalizeGhostPlacement(false)
|
||||
canvasElement.remove()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('moves the ghost node when pointer is near edge', () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
const posXBefore = node.pos[0]
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(node.pos[0]).not.toBe(posXBefore)
|
||||
})
|
||||
|
||||
it('does not pan when pointer is in the center', () => {
|
||||
canvas.mouse[0] = 400
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
const offsetBefore = [...canvas.ds.offset]
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
|
||||
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
|
||||
})
|
||||
|
||||
it('cleans up autopan and stops responding to document pointermove on finalize', () => {
|
||||
const processMoveSpy = vi.spyOn(canvas, 'processMouseMove')
|
||||
canvas.startGhostPlacement(node)
|
||||
expect(canvas['_autoPan']).not.toBeNull()
|
||||
|
||||
document.dispatchEvent(new MouseEvent('pointermove'))
|
||||
expect(processMoveSpy).toHaveBeenCalled()
|
||||
|
||||
processMoveSpy.mockClear()
|
||||
canvas.finalizeGhostPlacement(false)
|
||||
|
||||
expect(canvas['_autoPan']).toBeNull()
|
||||
|
||||
document.dispatchEvent(new MouseEvent('pointermove'))
|
||||
expect(processMoveSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives linkConnector reset during ghost placement', () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
canvas.linkConnector.reset()
|
||||
|
||||
expect(canvas['_autoPan']).not.toBeNull()
|
||||
vi.advanceTimersByTime(16)
|
||||
expect(canvas.ds.offset[0]).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LGraphCanvas ghost placement cancellation via document keydown', () => {
|
||||
let canvas: LGraphCanvas
|
||||
let canvasElement: HTMLCanvasElement
|
||||
let graph: LGraph
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
;({ canvas, canvasElement, graph, node } = createGhostTestHarness())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (canvas.state.ghostNodeId != null) canvas.finalizeGhostPlacement(false)
|
||||
canvasElement.remove()
|
||||
})
|
||||
|
||||
it('Escape on document removes the ghost node and clears ghost state', async () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
expect(canvas.state.ghostNodeId).toBe(node.id)
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
expect(canvas.state.ghostNodeId).toBeNull()
|
||||
expect(graph.getNodeById(node.id)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('Escape on document stops propagation so window-level keybindings do not fire', async () => {
|
||||
const windowSpy = vi.fn()
|
||||
window.addEventListener('keydown', windowSpy)
|
||||
try {
|
||||
canvas.startGhostPlacement(node)
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(windowSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
window.removeEventListener('keydown', windowSpy)
|
||||
}
|
||||
})
|
||||
|
||||
it('Delete and Backspace also cancel ghost placement', async () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
await userEvent.keyboard('{Delete}')
|
||||
expect(canvas.state.ghostNodeId).toBeNull()
|
||||
expect(graph.getNodeById(node.id)).toBeFalsy()
|
||||
|
||||
const node2 = new LGraphNode('test-2')
|
||||
node2.size = [200, 100]
|
||||
graph.add(node2)
|
||||
canvas.startGhostPlacement(node2)
|
||||
await userEvent.keyboard('{Backspace}')
|
||||
expect(canvas.state.ghostNodeId).toBeNull()
|
||||
expect(graph.getNodeById(node2.id)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('non-cancel keys do not finalize ghost placement', async () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
const windowSpy = vi.fn()
|
||||
window.addEventListener('keydown', windowSpy)
|
||||
try {
|
||||
await userEvent.keyboard('a')
|
||||
expect(canvas.state.ghostNodeId).toBe(node.id)
|
||||
expect(windowSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
window.removeEventListener('keydown', windowSpy)
|
||||
}
|
||||
})
|
||||
|
||||
it('keydown listener is removed when ghost placement finalizes', async () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
canvas.finalizeGhostPlacement(false)
|
||||
|
||||
const windowSpy = vi.fn()
|
||||
window.addEventListener('keydown', windowSpy)
|
||||
try {
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(windowSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
window.removeEventListener('keydown', windowSpy)
|
||||
}
|
||||
})
|
||||
|
||||
it('switching the active graph cancels any in-flight ghost', async () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
expect(canvas.state.ghostNodeId).toBe(node.id)
|
||||
|
||||
canvas.setGraph(new LGraph())
|
||||
|
||||
expect(canvas.state.ghostNodeId).toBeNull()
|
||||
expect(graph.getNodeById(node.id)).toBeFalsy()
|
||||
|
||||
// Listener should also be gone — Escape should reach the window now
|
||||
const windowSpy = vi.fn()
|
||||
window.addEventListener('keydown', windowSpy)
|
||||
try {
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(windowSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
window.removeEventListener('keydown', windowSpy)
|
||||
}
|
||||
})
|
||||
|
||||
it('calling startGhostPlacement again cancels the previous ghost without leaking listeners', async () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
const node2 = new LGraphNode('test-2')
|
||||
node2.size = [200, 100]
|
||||
graph.add(node2)
|
||||
canvas.startGhostPlacement(node2)
|
||||
|
||||
expect(graph.getNodeById(node.id)).toBeFalsy()
|
||||
expect(canvas.state.ghostNodeId).toBe(node2.id)
|
||||
|
||||
canvas.finalizeGhostPlacement(true)
|
||||
|
||||
const windowSpy = vi.fn()
|
||||
window.addEventListener('keydown', windowSpy)
|
||||
try {
|
||||
await userEvent.keyboard('{Escape}')
|
||||
// If a stale listener leaked, it would have stopPropagation'd this Escape.
|
||||
expect(windowSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
window.removeEventListener('keydown', windowSpy)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
setActor: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('LGraphCanvas ghost placement auto-pan', () => {
|
||||
let canvas: LGraphCanvas
|
||||
let canvasElement: HTMLCanvasElement
|
||||
let graph: LGraph
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
document.body.appendChild(canvasElement)
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {}
|
||||
})
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true,
|
||||
skip_events: true
|
||||
})
|
||||
|
||||
node = new LGraphNode('test')
|
||||
node.size = [200, 100]
|
||||
graph.add(node)
|
||||
|
||||
// Near left edge so autopan fires by default
|
||||
canvas.mouse[0] = 5
|
||||
canvas.mouse[1] = 300
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
canvasElement.remove()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('moves the ghost node when pointer is near edge', () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
const posXBefore = node.pos[0]
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(node.pos[0]).not.toBe(posXBefore)
|
||||
})
|
||||
|
||||
it('does not pan when pointer is in the center', () => {
|
||||
canvas.mouse[0] = 400
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
const offsetBefore = [...canvas.ds.offset]
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
|
||||
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
|
||||
})
|
||||
|
||||
it('cleans up autopan and document listener on finalize', () => {
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener')
|
||||
canvas.startGhostPlacement(node)
|
||||
expect(canvas['_autoPan']).not.toBeNull()
|
||||
|
||||
canvas.finalizeGhostPlacement(false)
|
||||
|
||||
expect(canvas['_autoPan']).toBeNull()
|
||||
expect(removeSpy).toHaveBeenCalledWith('pointermove', expect.any(Function))
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('survives linkConnector reset during ghost placement', () => {
|
||||
canvas.startGhostPlacement(node)
|
||||
|
||||
canvas.linkConnector.reset()
|
||||
|
||||
expect(canvas['_autoPan']).not.toBeNull()
|
||||
vi.advanceTimersByTime(16)
|
||||
expect(canvas.ds.offset[0]).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -683,6 +683,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
private _visibleReroutes: Set<Reroute> = new Set()
|
||||
private _autoPan: AutoPanController | null = null
|
||||
private _ghostPointerHandler: ((e: PointerEvent) => void) | null = null
|
||||
private _ghostKeyHandler: ((e: KeyboardEvent) => void) | null = null
|
||||
|
||||
dirty_canvas: boolean = true
|
||||
dirty_bgcanvas: boolean = true
|
||||
@@ -1859,6 +1860,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const { graph } = this
|
||||
if (newGraph === graph) return
|
||||
|
||||
// Drop any in-flight ghost so listeners don't outlive the graph it belongs to
|
||||
if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true)
|
||||
|
||||
this.clear()
|
||||
newGraph.attachCanvas(this)
|
||||
|
||||
@@ -3662,6 +3666,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* @param dragEvent Optional mouse event for positioning under cursor
|
||||
*/
|
||||
startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void {
|
||||
// Cancel any in-flight ghost so we don't leak its listeners
|
||||
if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true)
|
||||
|
||||
this.emitBeforeChange()
|
||||
this.graph?.beforeChange()
|
||||
|
||||
@@ -3701,6 +3708,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
'pointerleave',
|
||||
this._ghostPointerHandler
|
||||
)
|
||||
|
||||
// Listen on document so cancellation works even when the canvas isnt focused
|
||||
// e.g. the search dialog just closed.
|
||||
// stopPropagation prevents window-level keybindings (like Comfy.Graph.ExitSubgraph on Escape) from firing alongside the cancel.
|
||||
this._ghostKeyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' && e.key !== 'Delete' && e.key !== 'Backspace') {
|
||||
return
|
||||
}
|
||||
this.finalizeGhostPlacement(true)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('keydown', this._ghostKeyHandler, true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3729,6 +3749,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this._ghostPointerHandler = null
|
||||
}
|
||||
|
||||
if (this._ghostKeyHandler) {
|
||||
document.removeEventListener('keydown', this._ghostKeyHandler, true)
|
||||
this._ghostKeyHandler = null
|
||||
}
|
||||
|
||||
const node = this.graph?.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
@@ -3918,17 +3943,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const { graph } = this
|
||||
if (!graph) return
|
||||
|
||||
// Cancel ghost placement
|
||||
if (
|
||||
(e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') &&
|
||||
this.state.ghostNodeId != null
|
||||
) {
|
||||
this.finalizeGhostPlacement(true)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
let block_default = false
|
||||
// @ts-expect-error EventTarget.localName is not in standard types
|
||||
if (e.target.localName == 'input') return
|
||||
|
||||
@@ -187,16 +187,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
if (!widget) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
// Prefer serializeValue (per-instance) over the shared .value getter
|
||||
// so multiple SubgraphNode instances return their own configured values.
|
||||
const widgetValue = widget.serializeValue
|
||||
? widget.serializeValue(subgraphNode, -1)
|
||||
: widget.value
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widgetValue }
|
||||
widgetInfo: { value: widget.value }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: number = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 7)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
|
||||
instance1.graph!.add(instance1)
|
||||
instance2.graph!.add(instance2)
|
||||
|
||||
const widget1 = instance1.widgets?.[0]
|
||||
const widget2 = instance2.widgets?.[0]
|
||||
|
||||
expect(widget1?.value).toBe(7)
|
||||
expect(widget2?.value).toBe(7)
|
||||
|
||||
widget1!.value = 10
|
||||
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(7)
|
||||
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
|
||||
})
|
||||
|
||||
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 602,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
|
||||
widget.value = 45
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(45)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(45)
|
||||
})
|
||||
|
||||
it('clears stale per-instance values when reconfigured without widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
widget.value = 17
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
id: instance.id,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(instance.widgets?.[0].value).toBe(17)
|
||||
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
// it.fails pins the open #10849 SubgraphNode.configure regression on Main;
|
||||
// drop the marker once the inline-proxyWidgets-state fix lands.
|
||||
it.fails('falls back to source widget value when proxyWidgets is in legacy 2-tuple shape (regression for #10849)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE = 999
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 801 })
|
||||
instance.configure({
|
||||
id: 801,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [LEGACY_NOISE]
|
||||
})
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.serializeValue?.(instance, 0)).toBe(SOURCE_DEFAULT)
|
||||
})
|
||||
|
||||
it.fails('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const SOURCE_DEFAULT = 42
|
||||
const LEGACY_NOISE_A = 111
|
||||
const LEGACY_NOISE_B = 222
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 802 })
|
||||
instance.configure({
|
||||
id: 802,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [LEGACY_NOISE_A, LEGACY_NOISE_B]
|
||||
})
|
||||
|
||||
const widget = instance.widgets?.[0]
|
||||
expect(widget?.value).toBe(SOURCE_DEFAULT)
|
||||
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
|
||||
})
|
||||
})
|
||||
@@ -992,21 +992,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
/**
|
||||
* Per-instance promoted widget values.
|
||||
* Multiple SubgraphNode instances share the same inner nodes, so
|
||||
* promoted widget values must be stored per-instance to avoid collisions.
|
||||
* Key: `${sourceNodeId}:${sourceWidgetName}`
|
||||
*/
|
||||
readonly _instanceWidgetValues = new Map<string, unknown>()
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
this._instanceWidgetValues.clear()
|
||||
this._pendingWidgetsValues = info.widgets_values
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
@@ -1137,21 +1123,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
// Hydrate per-instance promoted widget values from serialized data.
|
||||
// LGraphNode.configure skips promoted widgets (serialize === false on
|
||||
// the view), so they must be applied here after promoted views exist.
|
||||
// Only iterate serializable views to match what serialize() wrote.
|
||||
if (this._pendingWidgetsValues) {
|
||||
const views = this._getPromotedViews()
|
||||
let i = 0
|
||||
for (const view of views) {
|
||||
if (!view.sourceSerialize) continue
|
||||
if (i >= this._pendingWidgetsValues.length) break
|
||||
view.value = this._pendingWidgetsValues[i++] as typeof view.value
|
||||
}
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1546,7 +1517,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._instanceWidgetValues.clear()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1602,7 +1572,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
@@ -1610,22 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
const serialized = super.serialize()
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
if (serializableViews.length > 0) {
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.serializeValue
|
||||
? view.serializeValue(this, -1)
|
||||
: view.value
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: (value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
return serialized
|
||||
return super.serialize()
|
||||
}
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
@@ -1017,6 +1017,7 @@
|
||||
"cancel": "إلغاء",
|
||||
"cancelled": "أُلغي",
|
||||
"capture": "التقاط",
|
||||
"categories": "الفئات",
|
||||
"category": "الفئة",
|
||||
"changeKeybinding": "تغيير اختصار المفتاح",
|
||||
"chart": "مخطط",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user