Compare commits
73 Commits
feat/snack
...
feat/Remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09942a5b7f | ||
|
|
560e53c68f | ||
|
|
1999b7fba0 | ||
|
|
285421a87c | ||
|
|
5523df1aea | ||
|
|
65876c635d | ||
|
|
28c97d3687 | ||
|
|
04918360eb | ||
|
|
97c2a0d364 | ||
|
|
af70d88860 | ||
|
|
c955309b26 | ||
|
|
7abd9d12c8 | ||
|
|
dd9cb42fa1 | ||
|
|
ccd19d8695 | ||
|
|
809fba7b36 | ||
|
|
df2ae6f2d0 | ||
|
|
3c7781190a | ||
|
|
167a1e6a0c | ||
|
|
e4e1546458 | ||
|
|
c1954028d1 | ||
|
|
5cad2c952b | ||
|
|
e356addeb6 | ||
|
|
e831daae59 | ||
|
|
96575fcec9 | ||
|
|
e7e1ae25a6 | ||
|
|
4ed00cec08 | ||
|
|
f566abdd6e | ||
|
|
3c5695fd42 | ||
|
|
4fff0c4b49 | ||
|
|
69dca2d600 | ||
|
|
004530b23a | ||
|
|
73d4e24ffa | ||
|
|
09790bd7f3 | ||
|
|
dafb944c3b | ||
|
|
d429d481e8 | ||
|
|
a9aae6af4a | ||
|
|
46ba65e25c | ||
|
|
11432f7d0e | ||
|
|
9384beaec6 | ||
|
|
4a05d89fdb | ||
|
|
ef98ba0e8f | ||
|
|
019c1787a5 | ||
|
|
87fab87d84 | ||
|
|
f2a99adaa3 | ||
|
|
a934056246 | ||
|
|
b8dfbfc0bb | ||
|
|
036c79259b | ||
|
|
17c18b0707 | ||
|
|
ca8407218b | ||
|
|
26ac1eece1 | ||
|
|
810381ab63 | ||
|
|
cc1a737291 | ||
|
|
c74e08e244 | ||
|
|
8f9f452c86 | ||
|
|
9e16390c33 | ||
|
|
c88275b2a4 | ||
|
|
23e48b2140 | ||
|
|
af43619ae1 | ||
|
|
d2e88011aa | ||
|
|
180a0001e8 | ||
|
|
8f011225bf | ||
|
|
3c50487c18 | ||
|
|
818e549e8e | ||
|
|
fd1a8e9432 | ||
|
|
8f61ecd82e | ||
|
|
8fe0385a57 | ||
|
|
d078af3a79 | ||
|
|
57d708767a | ||
|
|
b3f5f82216 | ||
|
|
9df4e02189 | ||
|
|
1c541d8577 | ||
|
|
389ff8ba49 | ||
|
|
bb74ec94de |
156
.claude/skills/reviewing-unit-tests/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: reviewing-unit-tests
|
||||
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
|
||||
---
|
||||
|
||||
# Reviewing Unit Tests for ComfyUI_frontend
|
||||
|
||||
## Overview
|
||||
|
||||
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
|
||||
|
||||
## Review Workflow
|
||||
|
||||
1. Identify the test type: component, store, composable, util, or bugfix regression.
|
||||
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
|
||||
3. Open the authoritative doc section before judging structure.
|
||||
4. Scan the red flags below.
|
||||
5. State the verdict first. Name the failure mode. Cite the doc or rule.
|
||||
|
||||
## Source of Truth / Precedence
|
||||
|
||||
When docs and examples conflict, use this order:
|
||||
|
||||
1. Explicit repo rules, lint rules, and note blocks.
|
||||
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
|
||||
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
|
||||
4. Example snippets
|
||||
5. Prior diffs
|
||||
|
||||
Apply these repo-specific clarifications:
|
||||
|
||||
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
|
||||
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
|
||||
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
|
||||
- Motion != fix.
|
||||
|
||||
## 30-Second Red Flags
|
||||
|
||||
| If you see... | Failure mode | Default action |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
|
||||
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
|
||||
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
|
||||
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
|
||||
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
|
||||
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
|
||||
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
|
||||
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
|
||||
|
||||
## Rationalization Table
|
||||
|
||||
| Excuse | Reality |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
|
||||
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
|
||||
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
|
||||
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
|
||||
| "It is only a nit" | Explicit repo-rule violations are never nits. |
|
||||
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
|
||||
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
|
||||
|
||||
## Mocking Rules
|
||||
|
||||
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
|
||||
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
|
||||
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
|
||||
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
|
||||
|
||||
### Alias-by-Renaming
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const mockAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
// After: same indirection, new name
|
||||
function getToastAddMock() {
|
||||
return useToast().add
|
||||
}
|
||||
```
|
||||
|
||||
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
|
||||
|
||||
### `vi.mocked()` Scope
|
||||
|
||||
| Use case | `vi.mocked()` required? |
|
||||
| --------------------------------------------------------------- | ----------------------- |
|
||||
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
|
||||
| `.mock.calls`, `.mock.results` | Yes |
|
||||
| `expect(fn).toHaveBeenCalled()` | No |
|
||||
| `expect(fn).toHaveBeenCalledWith(...)` | No |
|
||||
|
||||
- Flag casts whenever `vi.mocked()` would narrow correctly.
|
||||
- Do not add `vi.mocked()` around assertion-only references just for style.
|
||||
|
||||
### Reset Hygiene
|
||||
|
||||
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
|
||||
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
|
||||
|
||||
### Third-Party Seams
|
||||
|
||||
- Distinguish trivial hooks from behavior-rich APIs.
|
||||
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
|
||||
- That exception does not justify mocking behavior-rich third-party modules.
|
||||
|
||||
### `vue-i18n`
|
||||
|
||||
- Never mock `vue-i18n` in component tests.
|
||||
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
|
||||
|
||||
## Test-Body Rules
|
||||
|
||||
| Smell | Review bar |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Change-detector test | Reject. Default values alone prove nothing. |
|
||||
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
|
||||
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
|
||||
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
|
||||
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
|
||||
|
||||
## Bugfix Regression Proof
|
||||
|
||||
For `fix:` PRs or bugfix diffs:
|
||||
|
||||
1. Identify the production change that fixes the bug.
|
||||
2. Verify the new test fails on pre-fix code, or ask the author to show it.
|
||||
3. If the test passes on broken code, request changes.
|
||||
|
||||
A regression test that never proves red does not pin the bug.
|
||||
|
||||
## Review Output Rules
|
||||
|
||||
- State verdict before procedural questions.
|
||||
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
|
||||
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
|
||||
- Link the authoritative doc section in the review comment.
|
||||
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| When you see... | Read this |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
|
||||
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
|
||||
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
|
||||
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
23
.github/actions/ashby-pull/action.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Ashby Pull
|
||||
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
|
||||
required: true
|
||||
job_board_name:
|
||||
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh Ashby snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
|
||||
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot
|
||||
87
.github/actions/changes-filter/action.yaml
vendored
Normal file
@@ -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'
|
||||
BIN
.github/pr-images/fe-237-before-after.png
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -4,10 +4,8 @@ name: 'CI: Tests Unit'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
@@ -15,7 +13,20 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
run: vercel pull --yes --environment=preview
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -146,6 +149,9 @@ jobs:
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
25
.github/workflows/ci-website-build.yaml
vendored
@@ -4,23 +4,29 @@ name: 'CI: Website Build'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -30,4 +36,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
32
.github/workflows/ci-website-e2e.yaml
vendored
@@ -3,25 +3,29 @@ name: 'CI: Website E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
|
||||
packages-changes: ${{ steps.changes.outputs.packages-changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
website-e2e:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
@@ -45,6 +49,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Run Playwright tests
|
||||
@@ -161,7 +167,11 @@ jobs:
|
||||
post-starting-comment:
|
||||
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
|
||||
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
needs: changes
|
||||
if: |
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
|
||||
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
|
||||
@@ -86,6 +87,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Update screenshots
|
||||
@@ -137,7 +140,10 @@ jobs:
|
||||
name: 'Update Website Screenshots'
|
||||
})
|
||||
} catch (e) {
|
||||
// Label may already be removed
|
||||
if (e.status !== 404) {
|
||||
throw e
|
||||
}
|
||||
core.info('Label "Update Website Screenshots" was already removed')
|
||||
}
|
||||
|
||||
post-starting-comment:
|
||||
|
||||
59
.github/workflows/release-website.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-website
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: Refresh Ashby snapshot
|
||||
uses: ./.github/actions/ashby-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
delete-branch: true
|
||||
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Skip in CI: the canonical knip check runs in ci-lint-format on every
|
||||
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
|
||||
# setup-comfyui-server, which contaminates knip's project glob with the
|
||||
# devtools copy under custom_nodes and produces false-positive failures.
|
||||
if [ -n "${CI-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run Knip with cache via package script
|
||||
pnpm knip 1>&2
|
||||
|
||||
|
||||
@@ -113,6 +113,31 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
|
||||
```html
|
||||
<script
|
||||
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
|
||||
defer
|
||||
></script>
|
||||
<div
|
||||
class="hs-form-html"
|
||||
data-region="na2"
|
||||
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
|
||||
data-portal-id="244637579"
|
||||
></div>
|
||||
```
|
||||
|
||||
The localized `/zh-CN/contact` page uses the same portal and script with form
|
||||
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
|
||||
|
||||
This keeps submission handling, validation, anti-spam updates, and field
|
||||
configuration in HubSpot. The local implementation in
|
||||
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
|
||||
renders the documented embed container.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — Astro dev server
|
||||
|
||||
@@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
|
||||
page
|
||||
}) => {
|
||||
const contentColumn = page.getByTestId('case-study-content')
|
||||
const cta = contentColumn.getByRole('link', {
|
||||
name: /see all case studies/i
|
||||
})
|
||||
|
||||
await cta.scrollIntoViewIfNeeded()
|
||||
await expect(cta).toBeVisible()
|
||||
|
||||
const [columnBox, ctaBox] = await Promise.all([
|
||||
contentColumn.boundingBox(),
|
||||
cta.boundingBox()
|
||||
])
|
||||
|
||||
expect(columnBox).not.toBeNull()
|
||||
expect(ctaBox).not.toBeNull()
|
||||
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
|
||||
})
|
||||
|
||||
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
|
||||
page
|
||||
}) => {
|
||||
const contentColumn = page.getByTestId('case-study-content')
|
||||
const subheading = contentColumn.getByText(
|
||||
/Videos & case studies from teams/i
|
||||
)
|
||||
const cta = contentColumn.getByRole('link', {
|
||||
name: /see all case studies/i
|
||||
})
|
||||
|
||||
await cta.scrollIntoViewIfNeeded()
|
||||
|
||||
const [subBox, ctaBox] = await Promise.all([
|
||||
subheading.boundingBox(),
|
||||
cta.boundingBox()
|
||||
])
|
||||
|
||||
expect(subBox).not.toBeNull()
|
||||
expect(ctaBox).not.toBeNull()
|
||||
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
|
||||
})
|
||||
|
||||
test('BuildWhatSection is visible', async ({ page }) => {
|
||||
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
|
||||
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()
|
||||
|
||||
|
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:"
|
||||
},
|
||||
|
||||
58
apps/website/public/llms.txt
Normal file
@@ -0,0 +1,58 @@
|
||||
# Comfy
|
||||
|
||||
> Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Built around ComfyUI — the open-source node-graph runtime with 60,000+ community nodes and thousands of shared workflows — Comfy ships as a free local app, a managed cloud, an API, and an enterprise platform.
|
||||
|
||||
The Comfy ecosystem spans four surfaces:
|
||||
|
||||
- **ComfyUI (local)** — the open-source node-graph runtime that runs models on your own hardware.
|
||||
- **Comfy Cloud** — managed ComfyUI in the browser, with hosted models and storage.
|
||||
- **Comfy API** — a REST API for triggering workflows from your own apps and pipelines.
|
||||
- **Comfy Enterprise** — single-tenant deployments, BYO keys, data ownership, and orchestration for teams.
|
||||
|
||||
Studios building with Comfy include Series Entertainment, Moment Factory, Open Story Movement, and Ubisoft (La Forge). Use cases concentrate in VFX & animation, advertising & creative studios, gaming, and eCommerce/fashion.
|
||||
|
||||
## Product
|
||||
|
||||
- [Homepage](https://comfy.org/): Overview of Comfy and the four product surfaces (Local, Cloud, API, Enterprise).
|
||||
- [Download Comfy (Local)](https://comfy.org/download/): Free desktop app for macOS, Windows, and Linux — runs ComfyUI on your own GPU.
|
||||
- [Comfy Cloud](https://comfy.org/cloud/): Managed ComfyUI in the browser with hosted models and storage; no local install required.
|
||||
- [Comfy Cloud Pricing](https://comfy.org/cloud/pricing/): Plans and per-credit pricing for individuals and teams using Comfy Cloud.
|
||||
- [Comfy API](https://comfy.org/api/): REST API for triggering ComfyUI workflows programmatically from external apps.
|
||||
- [Comfy Enterprise](https://comfy.org/cloud/enterprise/): Single-tenant ComfyUI deployments with BYO keys, orchestration, and data-ownership guarantees.
|
||||
|
||||
## Workflows and Gallery
|
||||
|
||||
- [Workflow Gallery](https://comfy.org/gallery/): Curated showcase of ComfyUI outputs — images, video, and 3D — produced by the community.
|
||||
- [Community Workflows](https://www.comfy.org/workflows/): Browseable library of community-shared ComfyUI workflows you can load and remix.
|
||||
|
||||
## Customers and Case Studies
|
||||
|
||||
- [Customer Stories](https://comfy.org/customers/): Index of named customers and how they use ComfyUI in production.
|
||||
- [Series Entertainment](https://comfy.org/customers/series-entertainment/): How Series Entertainment rebuilt game and video production around ComfyUI.
|
||||
- [Moment Factory](https://comfy.org/customers/moment-factory/): Architectural-scale 3D projection mapping reimagined with ComfyUI at Moment Factory.
|
||||
- [Ubisoft — Chord](https://comfy.org/customers/ubisoft-chord/): Ubisoft La Forge open-sourcing the Chord model and its ComfyUI integration.
|
||||
- [Open Story Movement](https://comfy.org/customers/open-story-movement/): How an open-source movement around AI storytelling builds on ComfyUI.
|
||||
|
||||
## Developers and Documentation
|
||||
|
||||
- [ComfyUI Docs](https://docs.comfy.org/): Official documentation for installing, configuring, and extending ComfyUI.
|
||||
- [ComfyUI on GitHub](https://github.com/comfyanonymous/ComfyUI): Source repository for the open-source ComfyUI runtime.
|
||||
- [Comfy-Org on GitHub](https://github.com/Comfy-Org): Organization-wide repositories — frontend, registry, manager, docs, and tooling.
|
||||
- [Comfy Registry](https://registry.comfy.org/): Public registry of ComfyUI custom nodes and extensions, with versioning and search.
|
||||
|
||||
## Company
|
||||
|
||||
- [About Comfy](https://comfy.org/about/): Company background, mission, and the team behind ComfyUI.
|
||||
- [Careers](https://comfy.org/careers/): Open roles across engineering, design, product, and go-to-market.
|
||||
- [Contact](https://comfy.org/contact/): Sales, partnership, and general contact form.
|
||||
- [Blog](https://blog.comfy.org/): Product announcements, technical deep-dives, and customer stories.
|
||||
- [Privacy Policy](https://comfy.org/privacy-policy/): How Comfy collects, uses, and protects personal information.
|
||||
- [Terms of Service](https://comfy.org/terms-of-service/): Terms governing use of ComfyUI and related Comfy services.
|
||||
|
||||
## Optional
|
||||
|
||||
- [简体中文 / Chinese homepage](https://comfy.org/zh-CN/): Simplified Chinese localization of the main site.
|
||||
- [Series Entertainment — long-form case study](https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui): Extended write-up of the Series Entertainment deployment.
|
||||
- [Moment Factory — long-form case study](https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping): Extended write-up of Moment Factory's projection-mapping pipeline.
|
||||
- [Ubisoft Chord announcement (blog)](https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model): Original blog post announcing Ubisoft's open-source Chord model.
|
||||
- [Open-source storytelling (blog)](https://blog.comfy.org/p/how-open-source-is-fueling-the-open): Blog post on how open source is fueling the Open Story Movement.
|
||||
@@ -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
|
||||
|
||||
83
apps/website/scripts/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Website Scripts
|
||||
|
||||
## `refresh-ashby-snapshot.ts`
|
||||
|
||||
Pulls the latest job postings from Ashby and writes
|
||||
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
|
||||
GitHub Actions workflow; also runnable locally via
|
||||
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
|
||||
|
||||
## `process-videos.sh`
|
||||
|
||||
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
|
||||
frame for marketing videos using `ffmpeg`. Run **locally** before
|
||||
uploading the outputs to `media.comfy.org`; this is not wired into CI.
|
||||
|
||||
```sh
|
||||
apps/website/scripts/process-videos.sh \
|
||||
./video-sources \
|
||||
./dist/videos \
|
||||
"640 960 1280 1920"
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
For each source video at `./video-sources/foo.mp4`, you get:
|
||||
|
||||
```text
|
||||
foo-640.webm foo-640.mp4
|
||||
foo-960.webm foo-960.mp4
|
||||
foo-1280.webm foo-1280.mp4
|
||||
foo-1920.webm foo-1920.mp4
|
||||
foo-poster.jpg
|
||||
```
|
||||
|
||||
The naming convention is enforced by `buildVideoSources()` in
|
||||
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
|
||||
emit `<source>` URLs.
|
||||
|
||||
### Pairing with `<SiteVideo>`
|
||||
|
||||
Once the assets are uploaded, render them with:
|
||||
|
||||
```vue
|
||||
<SiteVideo
|
||||
name="foo"
|
||||
base-url="https://media.comfy.org/website/marketing"
|
||||
:width="1280"
|
||||
:formats="['webm', 'mp4']"
|
||||
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
|
||||
autoplay
|
||||
loop
|
||||
/>
|
||||
```
|
||||
|
||||
### `<SiteVideo>` vs `<VideoPlayer>`
|
||||
|
||||
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
|
||||
autoplay marketing clips. No custom controls, no captions UI.
|
||||
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
|
||||
fullscreen, and caption toggles. Use this for content with subtitles or
|
||||
user-driven playback.
|
||||
|
||||
If you need both responsive sources and the rich `VideoPlayer` chrome, the
|
||||
two are not yet combined; either pick one or extend `VideoPlayer` to accept
|
||||
a source list.
|
||||
|
||||
### Encoder choices
|
||||
|
||||
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
|
||||
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
|
||||
required for Safari iOS.
|
||||
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
|
||||
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
|
||||
the video shows something while loading.
|
||||
|
||||
### Why a single resolution per video
|
||||
|
||||
`<source media="...">` inside `<video>` is unreliable across browsers
|
||||
(Safari ignores it). The simplest correct strategy is to ship one
|
||||
well-sized resolution and let CSS scale it down on smaller viewports.
|
||||
The script generates multiple widths so you can pick a different one
|
||||
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
|
||||
JavaScript-based selection later if metrics demand it.
|
||||
110
apps/website/scripts/process-videos.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
|
||||
# for every source video in a given directory. Intended to be run locally
|
||||
# before uploading the outputs to media.comfy.org.
|
||||
#
|
||||
# Usage:
|
||||
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
|
||||
#
|
||||
# Example:
|
||||
# apps/website/scripts/process-videos.sh \
|
||||
# ./video-sources \
|
||||
# ./dist/videos \
|
||||
# "640 960 1280 1920"
|
||||
#
|
||||
# Defaults to widths "1280" if omitted.
|
||||
#
|
||||
# Output naming matches buildVideoSources() in src/utils/video.ts:
|
||||
# <name>-<width>.webm
|
||||
# <name>-<width>.mp4
|
||||
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
|
||||
#
|
||||
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
cat <<USAGE >&2
|
||||
Usage: $0 <input-dir> <output-dir> [widths]
|
||||
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
|
||||
USAGE
|
||||
exit 64
|
||||
fi
|
||||
|
||||
input_dir=$1
|
||||
output_dir=$2
|
||||
widths=${3:-1280}
|
||||
|
||||
for tool in ffmpeg ffprobe; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "error: $tool not found on PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -d $input_dir ]]; then
|
||||
echo "error: input dir not found: $input_dir" >&2
|
||||
exit 66
|
||||
fi
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
shopt -s nullglob nocaseglob
|
||||
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
|
||||
shopt -u nullglob nocaseglob
|
||||
|
||||
if [[ ${#sources[@]} -eq 0 ]]; then
|
||||
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
|
||||
exit 66
|
||||
fi
|
||||
|
||||
for src in "${sources[@]}"; do
|
||||
name=$(basename "$src")
|
||||
name=${name%.*}
|
||||
echo "==> $name"
|
||||
|
||||
for w in $widths; do
|
||||
webm_out="$output_dir/${name}-${w}.webm"
|
||||
mp4_out="$output_dir/${name}-${w}.mp4"
|
||||
|
||||
echo " encoding ${w}w VP9/WebM -> $webm_out"
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-i "$src" \
|
||||
-vf "scale=${w}:-2:flags=lanczos" \
|
||||
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
|
||||
-c:a libopus -b:a 96k \
|
||||
-f webm "$webm_out"
|
||||
|
||||
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-i "$src" \
|
||||
-vf "scale=${w}:-2:flags=lanczos" \
|
||||
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
|
||||
-c:a aac -b:a 128k \
|
||||
-movflags +faststart \
|
||||
"$mp4_out"
|
||||
done
|
||||
|
||||
poster_out="$output_dir/${name}-poster.jpg"
|
||||
duration_raw=$(ffprobe -v error -show_entries format=duration \
|
||||
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
|
||||
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
||||
duration="$duration_raw"
|
||||
else
|
||||
duration=0
|
||||
fi
|
||||
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
|
||||
poster_seek=1
|
||||
else
|
||||
poster_seek=0
|
||||
fi
|
||||
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-ss "$poster_seek" -i "$src" \
|
||||
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
|
||||
-q:v 4 \
|
||||
"$poster_out"
|
||||
done
|
||||
|
||||
echo "done. upload contents of $output_dir to media.comfy.org."
|
||||
51
apps/website/src/assets/marketing/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Marketing Assets
|
||||
|
||||
Source images committed here are processed by Astro at build time and emitted
|
||||
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
|
||||
|
||||
## Usage
|
||||
|
||||
Drop a high-resolution source image (PNG or JPG) here, then render it with
|
||||
Astro's built-in `<Picture>` component plus the shared defaults:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
import {
|
||||
MARKETING_FORMATS,
|
||||
MARKETING_WIDTHS
|
||||
} from '../utils/marketingImage'
|
||||
import hero from '../assets/marketing/hero.png'
|
||||
---
|
||||
<Picture
|
||||
src={hero}
|
||||
alt="ComfyUI workflow preview"
|
||||
formats={[...MARKETING_FORMATS]}
|
||||
widths={[...MARKETING_WIDTHS]}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
```
|
||||
|
||||
The component generates a `<picture>` element with `<source>` tags for AVIF
|
||||
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
|
||||
`dist/_website/` for long-term caching.
|
||||
|
||||
A custom Astro wrapper component is intentionally not provided: Astro's
|
||||
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
|
||||
a thin wrapper that mutates `widths` / `formats` impractical to type safely
|
||||
without `as` casts. The shared constants give us the same consistency benefit
|
||||
without that cost.
|
||||
|
||||
## When to use this vs. `media.comfy.org`
|
||||
|
||||
- **Use `src/assets/marketing/`** for static marketing images that are part of
|
||||
page content (hero shots, product imagery, illustrations). Build-time
|
||||
processing gives you AVIF/WebP variants automatically.
|
||||
- **Use `media.comfy.org`** for video content, large/changing image libraries
|
||||
(gallery), and anything shared across properties.
|
||||
|
||||
## Source image guidelines
|
||||
|
||||
- Provide the largest size you'll ever need (≥1920px wide).
|
||||
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
|
||||
- Astro will downscale; it will not upscale. Always supply at least 1920w.
|
||||
68
apps/website/src/components/common/SiteVideo.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { buildVideoSources, videoKey } from '../../utils/video'
|
||||
import type { VideoFormat } from '../../utils/video'
|
||||
|
||||
const {
|
||||
name,
|
||||
baseUrl,
|
||||
width = 1280,
|
||||
formats = ['webm', 'mp4'],
|
||||
poster,
|
||||
alt,
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
muted = autoplay,
|
||||
controls = false,
|
||||
preload = autoplay ? 'auto' : 'metadata',
|
||||
containerClass,
|
||||
videoClass
|
||||
} = defineProps<{
|
||||
name: string
|
||||
baseUrl: string
|
||||
width?: number
|
||||
formats?: VideoFormat[]
|
||||
poster?: string
|
||||
alt?: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
muted?: boolean
|
||||
controls?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none'
|
||||
containerClass?: string
|
||||
videoClass?: string
|
||||
}>()
|
||||
|
||||
const sources = computed(() =>
|
||||
buildVideoSources({ name, baseUrl, width, formats })
|
||||
)
|
||||
const remountKey = computed(() => videoKey(sources.value))
|
||||
const decorative = computed(() => !alt && !controls)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('relative', containerClass)">
|
||||
<video
|
||||
:key="remountKey"
|
||||
:class="cn('size-full', videoClass)"
|
||||
:poster
|
||||
:preload
|
||||
:autoplay
|
||||
:loop
|
||||
:muted
|
||||
:controls
|
||||
:aria-label="alt"
|
||||
:aria-hidden="decorative ? true : undefined"
|
||||
playsinline
|
||||
>
|
||||
<source
|
||||
v-for="source in sources"
|
||||
:key="source.src"
|
||||
:src="source.src"
|
||||
:type="source.type"
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
@@ -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>
|
||||
@@ -35,7 +35,10 @@ const routes = getRoutes(locale)
|
||||
</div>
|
||||
|
||||
<!-- Right: content -->
|
||||
<div class="flex flex-col justify-between p-6 lg:flex-1">
|
||||
<div
|
||||
data-testid="case-study-content"
|
||||
class="flex flex-col justify-between p-6 lg:flex-1"
|
||||
>
|
||||
<div class="flex flex-col gap-8">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
@@ -52,12 +55,8 @@ const routes = getRoutes(locale)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<BrandButton
|
||||
:href="routes.customers"
|
||||
variant="outline"
|
||||
class="flex-1 text-center"
|
||||
>
|
||||
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
|
||||
<BrandButton :href="routes.customers" variant="outline">
|
||||
{{ t('caseStudy.seeAll', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
@@ -32,6 +34,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
|
||||
>
|
||||
{{ t('hero.runFirstWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -101,17 +101,9 @@ const features: IncludedFeature[] = [
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
</p>
|
||||
<span
|
||||
v-if="feature.isComingSoon"
|
||||
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
|
||||
>
|
||||
{{ t('pricing.included.comingSoon', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
|
||||
@@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [
|
||||
detailPrefix: 'customers.detail.ubisoft-chord',
|
||||
readMoreHref:
|
||||
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
|
||||
},
|
||||
{
|
||||
slug: 'groove-jones',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
|
||||
category: 'customers.story.groove-jones.category',
|
||||
title: 'customers.story.groove-jones.title',
|
||||
body: 'customers.story.groove-jones.body',
|
||||
detailPrefix: 'customers.detail.groove-jones'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
{
|
||||
"fetchedAt": "2026-04-24T18:59:03.989Z",
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "e915f2c78b17f93b",
|
||||
"title": "Senior Product Designer",
|
||||
@@ -33,13 +19,6 @@
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "547b6ba622c800a5",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
@@ -115,6 +94,13 @@
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
},
|
||||
{
|
||||
"id": "2eb53e8943cc9396",
|
||||
"title": "Growth Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -122,6 +108,27 @@
|
||||
"name": "MARKETING",
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "b5803a0d4785d406",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
@@ -144,7 +151,7 @@
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Senior Technical Recruiter",
|
||||
"title": "Talent Lead",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
|
||||
@@ -11,6 +11,10 @@ const translations = {
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
'hero.runFirstWorkflow': {
|
||||
en: 'Run your first workflow',
|
||||
'zh-CN': '运行你的第一个工作流'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
'showcase.subtitle1': {
|
||||
@@ -910,9 +914,9 @@ const translations = {
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?'
|
||||
},
|
||||
'cloud.faq.3.a': {
|
||||
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
},
|
||||
'cloud.faq.4.q': {
|
||||
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
|
||||
@@ -1276,10 +1280,6 @@ const translations = {
|
||||
en: 'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
'zh-CN': '并行运行多个工作流,加速你的流程。'
|
||||
},
|
||||
'pricing.included.comingSoon': {
|
||||
en: 'coming soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
|
||||
// VideoPlayer
|
||||
'player.play': { en: 'Play', 'zh-CN': '播放' },
|
||||
@@ -2243,6 +2243,20 @@ const translations = {
|
||||
'zh-CN':
|
||||
'育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。'
|
||||
},
|
||||
'customers.story.groove-jones.category': {
|
||||
en: 'CASE STUDY',
|
||||
'zh-CN': '案例研究'
|
||||
},
|
||||
'customers.story.groove-jones.title': {
|
||||
en: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy",
|
||||
'zh-CN':
|
||||
"Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
|
||||
},
|
||||
'customers.story.groove-jones.body': {
|
||||
en: 'Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline.',
|
||||
'zh-CN':
|
||||
'达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。'
|
||||
},
|
||||
'customers.story.readMore': {
|
||||
en: 'READ MORE ON THIS TOPIC',
|
||||
'zh-CN': '阅读更多相关内容'
|
||||
@@ -3276,6 +3290,227 @@ const translations = {
|
||||
'zh-CN': 'ComfyUI 博客'
|
||||
},
|
||||
|
||||
// Customer Detail: Groove Jones
|
||||
// Topic 1: Intro
|
||||
'customers.detail.groove-jones.topic-1.label': {
|
||||
en: 'INTRO',
|
||||
'zh-CN': '简介'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-1.block.0': {
|
||||
en: 'Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick\u2019s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.',
|
||||
'zh-CN':
|
||||
'位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick\u2019s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-1.block.1': {
|
||||
en: 'For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick\u2019s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.',
|
||||
'zh-CN':
|
||||
'在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick\u2019s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。'
|
||||
},
|
||||
// Topic 2: The Output
|
||||
'customers.detail.groove-jones.topic-2.label': {
|
||||
en: 'THE OUTPUT',
|
||||
'zh-CN': '交付成果'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-2.title': {
|
||||
en: 'The Output Groove Jones Achieved Using Comfy',
|
||||
'zh-CN': 'Groove Jones 借助 Comfy 实现的交付成果'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-2.block.0': {
|
||||
en: 'A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline\nHyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick\u2019s Sporting Goods parking lots\nVertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts\nSame-day iteration on client notes instead of week-long asset updates\nWinner, Aaron Awards 2024: Best AI Workflow for Production',
|
||||
'zh-CN':
|
||||
'在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动\n超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick\u2019s Sporting Goods 停车场\n面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物\n客户反馈当天迭代,不再需要数周的资产更新周期\n荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖'
|
||||
},
|
||||
// Topic 3: The Problem
|
||||
'customers.detail.groove-jones.topic-3.label': {
|
||||
en: 'THE PROBLEM',
|
||||
'zh-CN': '挑战'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-3.title': {
|
||||
en: 'The Problem Groove Jones Was Trying to Solve',
|
||||
'zh-CN': 'Groove Jones 试图解决的问题'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-3.block.0': {
|
||||
en: 'A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team\u2019s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.',
|
||||
'zh-CN':
|
||||
'按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。'
|
||||
},
|
||||
// Topic 4: How Comfy Solved the Problem
|
||||
'customers.detail.groove-jones.topic-4.label': {
|
||||
en: 'HOW COMFY SOLVED THE PROBLEM',
|
||||
'zh-CN': 'Comfy 如何解决问题'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-4.title': {
|
||||
en: 'How Groove Jones Used Comfy to Solve the Problem',
|
||||
'zh-CN': 'Groove Jones 如何用 Comfy 解决问题'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-4.block.0': {
|
||||
en: 'Groove Jones\u2019s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy\u2019s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.',
|
||||
'zh-CN':
|
||||
'Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-4.block.1.text': {
|
||||
en: 'Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.',
|
||||
'zh-CN':
|
||||
'Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-4.block.1.name': {
|
||||
en: 'Doug Hogan | Senior Creative Technologist @ Groove Jones',
|
||||
'zh-CN': 'Doug Hogan | Groove Jones 高级创意技术总监'
|
||||
},
|
||||
// Topic 5: Brand-Trained LoRAs
|
||||
'customers.detail.groove-jones.topic-5.label': {
|
||||
en: 'BRAND-TRAINED LORAS',
|
||||
'zh-CN': '品牌定制 LORA'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-5.title': {
|
||||
en: 'Brand-Trained LoRAs for Hero Assets',
|
||||
'zh-CN': '为主视觉资产定制的品牌 LoRA'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-5.block.0': {
|
||||
en: 'Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick\u2019s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.',
|
||||
'zh-CN':
|
||||
'Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick\u2019s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-5.block.1.src': {
|
||||
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp',
|
||||
'zh-CN':
|
||||
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-5.block.1.alt': {
|
||||
en: 'Grid of brand-accurate NFL team Crocs generated via custom LoRAs',
|
||||
'zh-CN': '通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-5.block.1.caption': {
|
||||
en: 'Brand-accurate NFL team colorways generated through custom LoRAs.',
|
||||
'zh-CN': '通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。'
|
||||
},
|
||||
// Topic 6: Multi-Model Orchestration
|
||||
'customers.detail.groove-jones.topic-6.label': {
|
||||
en: 'MULTI-MODEL ORCHESTRATION',
|
||||
'zh-CN': '多模型编排'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-6.title': {
|
||||
en: 'Multi-Model Orchestration in a Single Graph',
|
||||
'zh-CN': '单张图内的多模型编排'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-6.block.0': {
|
||||
en: 'The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley\u2019s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.',
|
||||
'zh-CN':
|
||||
'这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-6.block.1.text': {
|
||||
en: 'The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.',
|
||||
'zh-CN':
|
||||
'Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-6.block.1.name': {
|
||||
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||
},
|
||||
// Topic 7: The Pipeline
|
||||
'customers.detail.groove-jones.topic-7.label': {
|
||||
en: 'THE PIPELINE',
|
||||
'zh-CN': '流水线'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.title': {
|
||||
en: 'Storyboards to Previz to Final Shot in One Pipeline',
|
||||
'zh-CN': '从故事板到 Previz 再到成片,全部在一条流水线内'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.0': {
|
||||
en: 'The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.',
|
||||
'zh-CN':
|
||||
'工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.1.src': {
|
||||
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp',
|
||||
'zh-CN':
|
||||
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.1.alt': {
|
||||
en: 'Storyboard grid for the Crocs x NFL holiday campaign',
|
||||
'zh-CN': 'Crocs x NFL 节日营销的故事板网格'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.1.caption': {
|
||||
en: 'Grayscale storyboards used to lock narrative beats before generation.',
|
||||
'zh-CN': '在生成之前用于锁定叙事节奏的灰度故事板。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.2.src': {
|
||||
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp',
|
||||
'zh-CN':
|
||||
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.2.alt': {
|
||||
en: 'Composition progression from blocking to mid-render to final shot',
|
||||
'zh-CN': '从 blocking 到中间渲染再到最终镜头的构图演进'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-7.block.2.caption': {
|
||||
en: 'Composition progression: wireframe blocking, mid-render, and final shot.',
|
||||
'zh-CN': '构图演进:线框 blocking、中间渲染、最终成片。'
|
||||
},
|
||||
// Topic 8: Version Control
|
||||
'customers.detail.groove-jones.topic-8.label': {
|
||||
en: 'VERSION CONTROL',
|
||||
'zh-CN': '版本管理'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-8.title': {
|
||||
en: 'Workflow Files as Version Control',
|
||||
'zh-CN': '把工作流文件当作版本管理'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-8.block.0': {
|
||||
en: 'Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.',
|
||||
'zh-CN':
|
||||
'每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。'
|
||||
},
|
||||
// Topic 9: Finishing in Nuke
|
||||
'customers.detail.groove-jones.topic-9.label': {
|
||||
en: 'FINISHING IN NUKE',
|
||||
'zh-CN': 'Nuke 终修'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-9.title': {
|
||||
en: 'Finishing in Nuke',
|
||||
'zh-CN': '在 Nuke 中完成终修'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-9.block.0': {
|
||||
en: 'Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.',
|
||||
'zh-CN':
|
||||
'生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。'
|
||||
},
|
||||
// Topic 10: The Takeaway
|
||||
'customers.detail.groove-jones.topic-10.label': {
|
||||
en: 'THE TAKEAWAY',
|
||||
'zh-CN': '总结'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.title': {
|
||||
en: 'Conclusion',
|
||||
'zh-CN': '结语'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.0': {
|
||||
en: 'By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.',
|
||||
'zh-CN':
|
||||
'通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.1.text': {
|
||||
en: 'At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.',
|
||||
'zh-CN':
|
||||
'在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.1.name': {
|
||||
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.label': {
|
||||
en: 'GROOVE JONES CONTRIBUTORS',
|
||||
'zh-CN': 'GROOVE JONES 贡献者'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.name': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.role': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
|
||||
// Contact – FormSection
|
||||
'contact.form.badge': {
|
||||
en: 'CONTACT SALES',
|
||||
@@ -3298,82 +3533,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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
3
apps/website/src/utils/marketingImage.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MARKETING_FORMATS = ['avif', 'webp'] as const
|
||||
|
||||
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const
|
||||
111
apps/website/src/utils/video.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildVideoSources, videoKey } from './video'
|
||||
|
||||
describe('buildVideoSources', () => {
|
||||
it('builds a source per requested format', () => {
|
||||
const sources = buildVideoSources({
|
||||
name: 'hero',
|
||||
baseUrl: 'https://media.comfy.org/website/marketing',
|
||||
width: 1280,
|
||||
formats: ['webm', 'mp4']
|
||||
})
|
||||
|
||||
expect(sources).toEqual([
|
||||
{
|
||||
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
|
||||
type: 'video/webm',
|
||||
format: 'webm'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
|
||||
type: 'video/mp4',
|
||||
format: 'mp4'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves caller-supplied format order', () => {
|
||||
const sources = buildVideoSources({
|
||||
name: 'clip',
|
||||
baseUrl: 'https://cdn.example.com/v',
|
||||
width: 960,
|
||||
formats: ['mp4', 'webm']
|
||||
})
|
||||
|
||||
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
|
||||
})
|
||||
|
||||
it('strips a single trailing slash from baseUrl', () => {
|
||||
const sources = buildVideoSources({
|
||||
name: 'reel',
|
||||
baseUrl: 'https://media.comfy.org/website/marketing/',
|
||||
width: 1920,
|
||||
formats: ['webm']
|
||||
})
|
||||
|
||||
expect(sources[0]?.src).toBe(
|
||||
'https://media.comfy.org/website/marketing/reel-1920.webm'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty list when no formats are requested', () => {
|
||||
const sources = buildVideoSources({
|
||||
name: 'x',
|
||||
baseUrl: 'https://example.com',
|
||||
width: 640,
|
||||
formats: []
|
||||
})
|
||||
|
||||
expect(sources).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('videoKey', () => {
|
||||
it('changes when the source URL list changes', () => {
|
||||
const at1280 = buildVideoSources({
|
||||
name: 'hero',
|
||||
baseUrl: 'https://media.comfy.org/m',
|
||||
width: 1280,
|
||||
formats: ['webm', 'mp4']
|
||||
})
|
||||
const at640 = buildVideoSources({
|
||||
name: 'hero',
|
||||
baseUrl: 'https://media.comfy.org/m',
|
||||
width: 640,
|
||||
formats: ['webm', 'mp4']
|
||||
})
|
||||
|
||||
expect(videoKey(at1280)).not.toBe(videoKey(at640))
|
||||
})
|
||||
|
||||
it('is stable across repeated calls with the same inputs', () => {
|
||||
const args = {
|
||||
name: 'hero',
|
||||
baseUrl: 'https://media.comfy.org/m',
|
||||
width: 1280,
|
||||
formats: ['webm', 'mp4'] as const
|
||||
}
|
||||
|
||||
expect(
|
||||
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
|
||||
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
|
||||
})
|
||||
|
||||
it('reflects format-order changes', () => {
|
||||
const webmFirst = buildVideoSources({
|
||||
name: 'hero',
|
||||
baseUrl: 'https://media.comfy.org/m',
|
||||
width: 1280,
|
||||
formats: ['webm', 'mp4']
|
||||
})
|
||||
const mp4First = buildVideoSources({
|
||||
name: 'hero',
|
||||
baseUrl: 'https://media.comfy.org/m',
|
||||
width: 1280,
|
||||
formats: ['mp4', 'webm']
|
||||
})
|
||||
|
||||
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
|
||||
})
|
||||
})
|
||||
49
apps/website/src/utils/video.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
format: VideoFormat
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
|
||||
webm: 'video/webm',
|
||||
mp4: 'video/mp4'
|
||||
}
|
||||
|
||||
type BuildArgs = {
|
||||
name: string
|
||||
baseUrl: string
|
||||
width: number
|
||||
formats: VideoFormat[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
|
||||
* matching the output of `apps/website/scripts/process-videos.sh`.
|
||||
*/
|
||||
export function buildVideoSources({
|
||||
name,
|
||||
baseUrl,
|
||||
width,
|
||||
formats
|
||||
}: BuildArgs): VideoSource[] {
|
||||
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
|
||||
|
||||
return formats.map((format) => ({
|
||||
src: `${base}/${name}-${width}.${format}`,
|
||||
type: MIME_TYPES[format],
|
||||
format
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identifier for a list of video sources, suitable as a Vue `key`.
|
||||
* Browsers do not reload a `<video>` when nested `<source>` children change;
|
||||
* keying the parent forces a remount when the source set changes.
|
||||
*/
|
||||
export function videoKey(sources: VideoSource[]): string {
|
||||
return sources.map((s) => s.src).join('|')
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
"github": {
|
||||
"enabled": false
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"has": [
|
||||
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
|
||||
],
|
||||
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
|
||||
@@ -96,6 +96,17 @@ pnpm test:browser:local # Run all tests
|
||||
pnpm test:browser:local widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Slowing the browser down for debugging
|
||||
|
||||
When running with `--headed` (or `--ui`), set `SLOW_MO` to a millisecond delay
|
||||
to slow every Playwright action down so you can watch what is happening. The
|
||||
delay only applies when `PLAYWRIGHT_LOCAL` is set (the default for the
|
||||
`pnpm test:browser:local` script).
|
||||
|
||||
```bash
|
||||
SLOW_MO=250 pnpm test:browser:local --headed widget.spec.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Browser tests in this project follow a specific organization pattern:
|
||||
|
||||
27
browser_tests/assets/3d/load3d_missing_model.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Preview3D",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 600],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3D",
|
||||
"Last Time Model File": "nonexistent_model.glb"
|
||||
},
|
||||
"widgets_values": ["nonexistent_model.glb"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "offset": [0, 0], "scale": 1 } },
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -119,7 +119,15 @@
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "v1-5-pruned-emaonly-fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Mouse } from '@playwright/test'
|
||||
import type { Locator, Mouse } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
@@ -72,6 +72,22 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async resizeByDragging(
|
||||
element: Locator,
|
||||
{ x, y }: { x?: number; y?: number }
|
||||
) {
|
||||
const elementBox = await element.boundingBox()
|
||||
if (!elementBox) throw new Error('element should have layout')
|
||||
|
||||
const cx = elementBox.x + elementBox.width / 2
|
||||
const cy = elementBox.y + elementBox.height / 2
|
||||
|
||||
await this.dragAndDrop(
|
||||
{ x: cx, y: cy },
|
||||
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
|
||||
)
|
||||
}
|
||||
|
||||
//#region Pass-through
|
||||
async click(...args: Parameters<Mouse['click']>) {
|
||||
return await this.mouse.click(...args)
|
||||
|
||||
@@ -30,6 +30,13 @@ export class VueNodeHelpers {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inner wrapper element of a Vue node.
|
||||
*/
|
||||
getNodeInnerWrapper(nodeId: string): Locator {
|
||||
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
@@ -119,10 +126,9 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -133,6 +139,15 @@ export class VueNodeHelpers {
|
||||
)
|
||||
}
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export class ComfyNodeSearchBoxV2 {
|
||||
readonly filterChips: Locator
|
||||
readonly noResults: Locator
|
||||
readonly nodeIdBadge: Locator
|
||||
readonly sidebarToggle: Locator
|
||||
readonly sidebarBackdrop: Locator
|
||||
readonly filterChipsScroll: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
@@ -28,6 +31,11 @@ export class ComfyNodeSearchBoxV2 {
|
||||
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
|
||||
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
|
||||
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
|
||||
this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle)
|
||||
this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop)
|
||||
this.filterChipsScroll = this.dialog.getByTestId(
|
||||
searchBoxV2.filterChipsScroll
|
||||
)
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
|
||||
54
browser_tests/fixtures/components/WidgetBoundingBox.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class BoundingBoxCoordinate {
|
||||
public readonly root: Locator
|
||||
public readonly input: Locator
|
||||
public readonly incrementButton: Locator
|
||||
public readonly decrementButton: Locator
|
||||
|
||||
constructor(root: Locator) {
|
||||
this.root = root
|
||||
this.input = root.locator('input')
|
||||
this.incrementButton = root.getByTestId(TestIds.widgets.increment)
|
||||
this.decrementButton = root.getByTestId(TestIds.widgets.decrement)
|
||||
}
|
||||
|
||||
async type(value: string | number): Promise<void> {
|
||||
await this.input.fill(String(value))
|
||||
await this.input.press('Enter')
|
||||
}
|
||||
|
||||
async focus(): Promise<void> {
|
||||
await this.input.focus()
|
||||
}
|
||||
|
||||
async increment(): Promise<void> {
|
||||
await this.incrementButton.click()
|
||||
}
|
||||
|
||||
async decrement(): Promise<void> {
|
||||
await this.decrementButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class WidgetBoundingBoxFixture {
|
||||
public readonly root: Locator
|
||||
public readonly x: BoundingBoxCoordinate
|
||||
public readonly y: BoundingBoxCoordinate
|
||||
public readonly width: BoundingBoxCoordinate
|
||||
public readonly height: BoundingBoxCoordinate
|
||||
|
||||
constructor(parent: Locator) {
|
||||
this.root = parent.getByTestId('bounding-box')
|
||||
this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x'))
|
||||
this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y'))
|
||||
this.width = new BoundingBoxCoordinate(
|
||||
this.root.getByTestId('bounding-box-width')
|
||||
)
|
||||
this.height = new BoundingBoxCoordinate(
|
||||
this.root.getByTestId('bounding-box-height')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,21 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
export async function enableErrorsOverlay(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Dismiss the error overlay (the floating dialog with the dismiss button). */
|
||||
export async function dismissErrorOverlay(comfyPage: ComfyPage): Promise<void> {
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
}
|
||||
|
||||
export async function loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
@@ -1,9 +1,35 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
*/
|
||||
export function buildKSamplerError(
|
||||
type: NodeError['errors'][number]['type'],
|
||||
inputName: string,
|
||||
message: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type,
|
||||
message,
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for simulating prompt execution in e2e tests.
|
||||
*/
|
||||
@@ -16,13 +42,23 @@ export class ExecutionHelper {
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
private readonly ws: WebSocketRoute
|
||||
private readonly ws?: WebSocketRoute
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.command = comfyPage.command
|
||||
this.assets = comfyPage.assets
|
||||
}
|
||||
|
||||
private requireWs(): WebSocketRoute {
|
||||
if (!this.ws) {
|
||||
throw new Error(
|
||||
'ExecutionHelper was constructed without a WebSocketRoute; ' +
|
||||
'pass `ws` to use methods that send WS frames.'
|
||||
)
|
||||
}
|
||||
return this.ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||
* the synthetic job ID.
|
||||
@@ -39,7 +75,7 @@ export class ExecutionHelper {
|
||||
})
|
||||
|
||||
await this.page.route(
|
||||
'**/api/prompt',
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -60,6 +96,31 @@ export class ExecutionHelper {
|
||||
return jobId
|
||||
}
|
||||
|
||||
async mockValidationFailure(
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Promise<void> {
|
||||
const response: PromptResponse = {
|
||||
node_errors: nodeErrors,
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Prompt outputs failed validation',
|
||||
details: ''
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||
@@ -89,12 +150,12 @@ export class ExecutionHelper {
|
||||
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||
|
||||
this.ws.send(Buffer.from(buf))
|
||||
this.requireWs().send(Buffer.from(buf))
|
||||
}
|
||||
|
||||
/** Send `execution_start` WS event. */
|
||||
executionStart(jobId: string): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -104,7 +165,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `executing` WS event to signal which node is currently running. */
|
||||
executing(jobId: string, nodeId: string | null): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { prompt_id: jobId, node: nodeId }
|
||||
@@ -118,7 +179,7 @@ export class ExecutionHelper {
|
||||
nodeId: string,
|
||||
output: Record<string, unknown>
|
||||
): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'executed',
|
||||
data: {
|
||||
@@ -133,7 +194,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_success` WS event. */
|
||||
executionSuccess(jobId: string): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_success',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -143,7 +204,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_error` WS event. */
|
||||
executionError(jobId: string, nodeId: string, message: string): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_error',
|
||||
data: {
|
||||
@@ -161,7 +222,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||
@@ -201,7 +262,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `status` WS event to update queue count. */
|
||||
status(queueRemaining: number): void {
|
||||
this.ws.send(
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||
|
||||
@@ -210,7 +210,9 @@ export const TestIds = {
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list',
|
||||
notificationBanner: 'queue-notification-banner'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
@@ -261,6 +263,9 @@ export const TestIds = {
|
||||
chipDelete: 'chip-delete',
|
||||
noResults: 'no-results',
|
||||
nodeIdBadge: 'node-id-badge',
|
||||
sidebarToggle: 'toggle-category-sidebar',
|
||||
sidebarBackdrop: 'sidebar-backdrop',
|
||||
filterChipsScroll: 'filter-chips-scroll',
|
||||
category: (id: string) => `category-${id}`,
|
||||
rootCategory: (id: string) => `search-category-${id}`,
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { cleanupFakeModel } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -282,6 +282,57 @@ test.describe('Load3D', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load3D silent 404 on missing output model', () => {
|
||||
test('Does not show an error toast when the output model file is missing (404)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept model fetch and return 404 to simulate a missing output file
|
||||
// (e.g. shared workflow opened on a machine that never ran it)
|
||||
await comfyPage.page.route('**/view?**', (route) =>
|
||||
route.fulfill({ status: 404, body: 'Not Found' })
|
||||
)
|
||||
|
||||
// This workflow has a Preview3D node with Last Time Model File set,
|
||||
// triggering the loadFolder: 'output' + silentOnNotFound: true path.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
// Wait for the 404 response before asserting — gives the load attempt time
|
||||
// to complete without using waitForTimeout
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
|
||||
await responsePromise
|
||||
|
||||
await expect(
|
||||
comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Shows an error toast when a non-404 error occurs loading the output model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept with a 500 to simulate a real server error (not 404) — toast must appear
|
||||
await comfyPage.page.route('**/view?**', (route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/view?**')
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model')
|
||||
await responsePromise
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.toast.visibleToasts
|
||||
.filter({ hasText: 'Error loading model' })
|
||||
.count(),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load3D initialization failure', () => {
|
||||
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -267,5 +267,65 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
if (mode === 'vue') {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
} else {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
}
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
const subgraphId = await comfyPage.subgraph.getActiveGraphId()
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.state.ghostNodeId != null
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), {
|
||||
message:
|
||||
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getActiveGraphId())
|
||||
.toBe(subgraphId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
|
||||
)
|
||||
.toBeNull()
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,4 +125,151 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Category sidebar', () => {
|
||||
test('Sidebar toggle hides and shows the category sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
const samplingCategory = searchBoxV2.categoryButton('sampling')
|
||||
await expect(samplingCategory).toBeVisible()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(samplingCategory).toBeHidden()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect(samplingCategory).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
// Narrow viewport so the chips overflow the filter bar
|
||||
await comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
await searchBoxV2.open()
|
||||
|
||||
const scrollEl = searchBoxV2.filterChipsScroll
|
||||
const dims = await scrollEl.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth
|
||||
}))
|
||||
expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth)
|
||||
|
||||
await scrollEl.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
|
||||
// The toggle lives outside the scroll container, so even when the
|
||||
// chips scroll hundreds of px it must remain visible in the viewport.
|
||||
await expect(searchBoxV2.sidebarToggle).toBeInViewport()
|
||||
})
|
||||
|
||||
test('@mobile Sidebar is collapsed by default on mobile', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
|
||||
})
|
||||
|
||||
test('@mobile Clicking outside the sidebar closes it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeVisible()
|
||||
await expect(searchBoxV2.sidebarBackdrop).toBeVisible()
|
||||
|
||||
// The backdrop spans the full content area, but the sidebar (z-20)
|
||||
// covers its left ~208px (w-52). Click past that to land on the
|
||||
// backdrop rather than the sidebar.
|
||||
await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } })
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
|
||||
await expect(searchBoxV2.sidebarBackdrop).toBeHidden()
|
||||
})
|
||||
|
||||
test('@mobile Focusing the search input closes the sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
|
||||
await searchBoxV2.input.focus()
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
test('Sidebar state across mobile/desktop resizes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const switchToDesktop = () =>
|
||||
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
|
||||
const switchToMobile = () =>
|
||||
comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
value
|
||||
)
|
||||
|
||||
await switchToDesktop()
|
||||
await searchBoxV2.open()
|
||||
await expectExpanded('true')
|
||||
|
||||
await switchToMobile()
|
||||
await expectExpanded('false')
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await switchToDesktop()
|
||||
await expectExpanded('true')
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await switchToMobile()
|
||||
await expectExpanded('false')
|
||||
|
||||
await switchToDesktop()
|
||||
await expectExpanded('false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
cleanupFakeModel,
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
121
browser_tests/tests/queue/queueSettings.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { Locator, Page, Request } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const TOTAL_MOCK_JOBS = 20
|
||||
const overflowJobsListRoutePattern = '**/api/jobs?*'
|
||||
|
||||
function isHistoryJobsRequest(url: string): boolean {
|
||||
if (!url.includes('/api/jobs')) return false
|
||||
const params = new URL(url).searchParams
|
||||
const statuses = (params.get('status') ?? '').split(',')
|
||||
return statuses.includes('completed')
|
||||
}
|
||||
|
||||
async function captureNextHistoryRequest(
|
||||
comfyPage: ComfyPage,
|
||||
exec: ExecutionHelper
|
||||
): Promise<Request> {
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
(req) => isHistoryJobsRequest(req.url()),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
exec.status(0)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
function getJobListResults(page: Page): Locator {
|
||||
return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]')
|
||||
}
|
||||
|
||||
test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Queue.MaxHistoryItems', () => {
|
||||
test.describe('limit query parameter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(
|
||||
createMockJobs(TOTAL_MOCK_JOBS)
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('limit query parameter on /api/jobs reflects the setting', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const TARGET_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
TARGET_LIMIT
|
||||
)
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const request = await captureNextHistoryRequest(comfyPage, exec)
|
||||
const url = new URL(request.url())
|
||||
expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
test('queue panel caps history items to the configured number', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
// Add a mock route that returns all jobs regardless of the request's `limit` param
|
||||
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
|
||||
await comfyPage.page.route(
|
||||
overflowJobsListRoutePattern,
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (!url.searchParams.get('status')?.includes('completed')) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
const response = {
|
||||
jobs: overflowJobs,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: overflowJobs.length,
|
||||
total: overflowJobs.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const VISIBLE_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
VISIBLE_LIMIT
|
||||
)
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await captureNextHistoryRequest(comfyPage, exec)
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
const jobs = getJobListResults(comfyPage.page)
|
||||
await expect(jobs.first()).toBeVisible()
|
||||
await expect(jobs).toHaveCount(VISIBLE_LIMIT)
|
||||
})
|
||||
})
|
||||
})
|
||||
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// Mirrors BANNER_DISMISS_DELAY_MS in src/composables/queue/useQueueNotificationBanners.ts.
|
||||
// Duplicated here to avoid pulling production source (and its litegraph
|
||||
// transitive deps) into the Playwright TS loader.
|
||||
const BANNER_DISMISS_DELAY_MS = 4000
|
||||
const BANNER_ASSERT_TIMEOUT_MS = BANNER_DISMISS_DELAY_MS + 2000
|
||||
|
||||
const REQUEST_ID_PRIMARY = 1
|
||||
const REQUEST_ID_SECONDARY = 2
|
||||
const REQUEST_ID_MISMATCH = 999
|
||||
|
||||
let nextRequestId = 1000
|
||||
const newRequestId = () => nextRequestId++
|
||||
|
||||
function bannerLocator(page: Page) {
|
||||
return page.getByTestId(TestIds.queue.notificationBanner)
|
||||
}
|
||||
|
||||
type DispatchOpts = { batchCount?: number; requestId?: number }
|
||||
|
||||
function dispatchPromptQueueing(page: Page, opts: DispatchOpts = {}) {
|
||||
return page.evaluate(
|
||||
([batchCount, requestId]) => {
|
||||
window.app!.api.dispatchCustomEvent('promptQueueing', {
|
||||
batchCount,
|
||||
requestId
|
||||
})
|
||||
},
|
||||
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
|
||||
)
|
||||
}
|
||||
|
||||
function dispatchPromptQueued(page: Page, opts: DispatchOpts = {}) {
|
||||
return page.evaluate(
|
||||
([batchCount, requestId]) => {
|
||||
window.app!.api.dispatchCustomEvent('promptQueued', {
|
||||
number: 0,
|
||||
batchCount,
|
||||
requestId
|
||||
})
|
||||
},
|
||||
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Queue notification banners', { tag: ['@ui'] }, () => {
|
||||
test.describe('Queuing lifecycle', () => {
|
||||
test('promptQueueing event shows a queueing banner', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueueing(comfyPage.page)
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('queuing')
|
||||
})
|
||||
|
||||
test('promptQueued upgrades a pending banner to queued', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueueing(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toContainText('queuing')
|
||||
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
await expect(banner).toContainText('queued')
|
||||
})
|
||||
|
||||
test('promptQueued with batch count > 1 shows plural text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueued(comfyPage.page, { batchCount: 3 })
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('3')
|
||||
await expect(banner).toContainText('jobs added to queue')
|
||||
})
|
||||
|
||||
test('promptQueued with mismatched requestId enqueues a separate queued banner', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueueing(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toContainText('queuing')
|
||||
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_MISMATCH
|
||||
})
|
||||
|
||||
// Pending banner is not upgraded — still shows "queuing".
|
||||
await expect(banner).toContainText('queuing')
|
||||
|
||||
// After the pending banner auto-dismisses, the queued banner appears.
|
||||
await expect(banner).toContainText('queued', {
|
||||
timeout: BANNER_ASSERT_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Auto-dismiss', () => {
|
||||
test('Banner auto-dismisses after timeout', async ({ comfyPage }) => {
|
||||
await dispatchPromptQueued(comfyPage.page)
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toBeHidden({ timeout: BANNER_ASSERT_TIMEOUT_MS })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Notification queue (FIFO)', () => {
|
||||
test('Second notification shows after first auto-dismisses', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 2,
|
||||
requestId: REQUEST_ID_SECONDARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toContainText('Job queued')
|
||||
await expect(banner).toContainText('2 jobs added to queue', {
|
||||
timeout: BANNER_ASSERT_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Direct queued event (no pending predecessor)', () => {
|
||||
test('promptQueued without prior queueing shows queued banner directly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('queued')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -42,8 +42,11 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
// Selection toolbox should be visible with multiple nodes selected
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
// Border is now drawn on canvas, check via screenshot
|
||||
// Allow small anti-aliasing variance on the canvas-drawn selection border
|
||||
// (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'selection-toolbox-multiple-nodes-border.png'
|
||||
'selection-toolbox-multiple-nodes-border.png',
|
||||
{ maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1012,3 +1012,42 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - drag and drop', () => {
|
||||
test('Dragging outputs from assets skips upload', async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job',
|
||||
preview_output: {
|
||||
filename: `test.png`,
|
||||
type: 'temp',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
expect(true, 'file is not uploaded').toBe(false)
|
||||
return route.fulfill({ status: 405 })
|
||||
})
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('.')
|
||||
const { assetsTab } = comfyPage.menu
|
||||
await assetsTab.open()
|
||||
await assetsTab.waitForAssets()
|
||||
await expect(assetsTab.assetCards).toHaveCount(1)
|
||||
|
||||
const targetPosition =
|
||||
(await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')) ??
|
||||
undefined
|
||||
|
||||
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
|
||||
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
const fileComboWidget = await nodes[0].getWidget(0)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -42,31 +40,6 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
@@ -525,29 +498,4 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,10 +121,7 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'vue-groups-create-group.png'
|
||||
)
|
||||
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should allow fitting group to contents', async ({ comfyPage }) => {
|
||||
|
||||
|
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -2,6 +2,10 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
|
||||
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
|
||||
|
||||
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -20,15 +24,11 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
function getNode(comfyPage: ComfyPage) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
function getWidgets(comfyPage: ComfyPage) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
@@ -58,20 +58,41 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
await node.getByText(SHOW_ADVANCED_INPUTS).click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await node.getByText(HIDE_ADVANCED_INPUTS).click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should hide advanced footer button while collapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
|
||||
const vueNode =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeHidden()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -92,6 +113,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText('Show advanced inputs')).toBeHidden()
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
50
browser_tests/tests/vueNodes/widgets/legacy.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await test.step('setup', async () => {
|
||||
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
|
||||
})
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
)
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
|
||||
const { width, height } = (await legacyWidget.boundingBox())!
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
|
||||
const legacyWidgetRef = await nodeRef.getWidget(0)
|
||||
expect(await legacyWidgetRef.getValue()).toBe(0)
|
||||
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
|
||||
await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1)
|
||||
await legacyWidget.click({ position: { x: width - 20, y: height / 2 } })
|
||||
await expect.poll(() => legacyWidgetRef.getValue()).toBe(0)
|
||||
})
|
||||
|
||||
await test.step('Resize to update width', async () => {
|
||||
const initialWidth = await getWidth()
|
||||
expect(initialWidth).toBeGreaterThan(0)
|
||||
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
await expect(gutter).toBeVisible()
|
||||
await comfyMouse.resizeByDragging(gutter, { x: -200 })
|
||||
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
|
||||
const intermediateWidth = await getWidth()
|
||||
|
||||
await comfyMouse.resizeByDragging(gutter, { x: 100 })
|
||||
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
|
||||
})
|
||||
})
|
||||
185
browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts
Normal file
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -54,8 +54,13 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
'tools/devtools/web/**'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.12",
|
||||
"version": "1.44.15",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -83,6 +83,7 @@
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
@@ -113,6 +114,7 @@
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
177
scripts/generate-embedded-metadata-test-files.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate test fixture files for metadata parser tests.
|
||||
|
||||
Each fixture embeds the same workflow and prompt JSON, matching the
|
||||
format the ComfyUI backend uses to write metadata.
|
||||
|
||||
Prerequisites:
|
||||
source ~/ComfyUI/.venv/bin/activate
|
||||
python3 scripts/generate-embedded-metadata-test-files.py
|
||||
|
||||
Output: src/scripts/metadata/__fixtures__/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
|
||||
WORKFLOW = {
|
||||
'nodes': [
|
||||
{
|
||||
'id': 1,
|
||||
'type': 'KSampler',
|
||||
'pos': [100, 100],
|
||||
'size': [200, 200],
|
||||
}
|
||||
]
|
||||
}
|
||||
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
|
||||
|
||||
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
|
||||
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
|
||||
|
||||
|
||||
def out(name: str) -> str:
|
||||
return os.path.join(FIXTURES_DIR, name)
|
||||
|
||||
|
||||
def report(name: str):
|
||||
size = os.path.getsize(out(name))
|
||||
print(f' {name} ({size} bytes)')
|
||||
|
||||
|
||||
def make_1x1_image() -> Image.Image:
|
||||
return Image.new('RGB', (1, 1), (255, 0, 0))
|
||||
|
||||
|
||||
def build_exif_bytes() -> bytes:
|
||||
"""Build EXIF bytes matching the backend's tag assignments.
|
||||
|
||||
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
exif = img.getexif()
|
||||
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
|
||||
exif[0x0110] = f'prompt:{PROMPT_JSON}'
|
||||
return exif.tobytes()
|
||||
|
||||
|
||||
def inject_exif_prefix_in_webp(path: str):
|
||||
"""Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file.
|
||||
|
||||
PIL always strips this prefix, so we re-inject it to test that code path.
|
||||
"""
|
||||
data = bytearray(open(path, 'rb').read())
|
||||
off = 12
|
||||
while off < len(data):
|
||||
chunk_type = data[off:off + 4]
|
||||
chunk_len = struct.unpack_from('<I', data, off + 4)[0]
|
||||
if chunk_type == b'EXIF':
|
||||
prefix = b'Exif\x00\x00'
|
||||
data[off + 8:off + 8] = prefix
|
||||
struct.pack_into('<I', data, off + 4, chunk_len + len(prefix))
|
||||
riff_size = struct.unpack_from('<I', data, 4)[0]
|
||||
struct.pack_into('<I', data, 4, riff_size + len(prefix))
|
||||
break
|
||||
off += 8 + chunk_len + (chunk_len % 2)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def generate_av_fixture(
|
||||
name: str,
|
||||
fmt: str,
|
||||
codec: str,
|
||||
rate: int = 44100,
|
||||
options: dict | None = None,
|
||||
):
|
||||
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
|
||||
path = out(name)
|
||||
container = av.open(path, mode='w', format=fmt, options=options or {})
|
||||
stream = container.add_stream(codec, rate=rate)
|
||||
stream.layout = 'mono'
|
||||
|
||||
container.metadata['prompt'] = PROMPT_JSON
|
||||
container.metadata['workflow'] = WORKFLOW_JSON
|
||||
|
||||
sample_fmt = stream.codec_context.codec.audio_formats[0].name
|
||||
samples = stream.codec_context.frame_size or 1024
|
||||
frame = av.AudioFrame(format=sample_fmt, layout='mono', samples=samples)
|
||||
frame.rate = rate
|
||||
frame.pts = 0
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
container.close()
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
|
||||
img.save(out('with_metadata.webp'), 'WEBP', exif=exif)
|
||||
report('with_metadata.webp')
|
||||
|
||||
img.save(out('with_metadata_exif_prefix.webp'), 'WEBP', exif=exif)
|
||||
inject_exif_prefix_in_webp(out('with_metadata_exif_prefix.webp'))
|
||||
report('with_metadata_exif_prefix.webp')
|
||||
|
||||
|
||||
def generate_avif():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
img.save(out('with_metadata.avif'), 'AVIF', exif=exif)
|
||||
report('with_metadata.avif')
|
||||
|
||||
|
||||
def generate_flac():
|
||||
generate_av_fixture('with_metadata.flac', 'flac', 'flac')
|
||||
|
||||
|
||||
def generate_opus():
|
||||
generate_av_fixture('with_metadata.opus', 'opus', 'libopus', rate=48000)
|
||||
|
||||
|
||||
def generate_mp3():
|
||||
generate_av_fixture('with_metadata.mp3', 'mp3', 'libmp3lame')
|
||||
|
||||
|
||||
def generate_mp4():
|
||||
"""Generate MP4 via ffmpeg CLI with QuickTime keys/ilst metadata."""
|
||||
path = out('with_metadata.mp4')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
||||
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
|
||||
'-movflags', 'use_metadata_tags',
|
||||
'-metadata', f'prompt={PROMPT_JSON}',
|
||||
'-metadata', f'workflow={WORKFLOW_JSON}',
|
||||
path,
|
||||
], check=True)
|
||||
report('with_metadata.mp4')
|
||||
|
||||
|
||||
def generate_webm():
|
||||
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
generate_opus()
|
||||
generate_mp3()
|
||||
generate_mp4()
|
||||
generate_webm()
|
||||
print('Done.')
|
||||
@@ -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>
|
||||
|
||||
@@ -153,17 +153,15 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragDrop(e: DragEvent) {
|
||||
for (const { nodeData } of mappedSelections.value) {
|
||||
if (!nodeData?.onDragOver?.(e)) continue
|
||||
|
||||
const rawResult = nodeData?.onDragDrop?.(e)
|
||||
if (rawResult === false) continue
|
||||
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if ((await rawResult) === true) return
|
||||
async function handleDragDrop() {
|
||||
const onDragDrop = async (e: DragEvent) => {
|
||||
for (const { nodeData } of mappedSelections.value)
|
||||
if (nodeData?.onDragOver?.(e) && (await nodeData.onDragDrop?.(e)))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
app.dragOverNode = { id: -1, onDragDrop }
|
||||
}
|
||||
|
||||
defineExpose({ handleDragDrop })
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
153
src/components/load3d/Load3DScene.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
|
||||
const dragState = vi.hoisted(() => ({
|
||||
isDragging: null as Ref<boolean> | null,
|
||||
dragMessage: null as Ref<string> | null,
|
||||
handleDragOver: vi.fn(),
|
||||
handleDragLeave: vi.fn(),
|
||||
handleDrop: vi.fn(),
|
||||
capturedOptions: null as {
|
||||
onModelDrop?: (file: File) => Promise<void>
|
||||
disabled?: { value?: boolean } | boolean
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dDrag', () => ({
|
||||
useLoad3dDrag: (options: unknown) => {
|
||||
dragState.capturedOptions = options as typeof dragState.capturedOptions
|
||||
return {
|
||||
isDragging: dragState.isDragging!,
|
||||
dragMessage: dragState.dragMessage!,
|
||||
handleDragOver: dragState.handleDragOver,
|
||||
handleDragLeave: dragState.handleDragLeave,
|
||||
handleDrop: dragState.handleDrop
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
|
||||
default: {
|
||||
name: 'LoadingOverlayStub',
|
||||
props: ['loading', 'loadingMessage'],
|
||||
template: `
|
||||
<div data-testid="loading-overlay">
|
||||
<span v-if="loading">{{ loadingMessage }}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
type RenderOpts = {
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
isPreview?: boolean
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
initializeLoad3d?: (container: HTMLElement) => Promise<void>
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const initializeLoad3d =
|
||||
opts.initializeLoad3d ?? vi.fn().mockResolvedValue(undefined)
|
||||
const cleanup = opts.cleanup ?? vi.fn()
|
||||
|
||||
const utils = render(Load3DScene, {
|
||||
props: {
|
||||
initializeLoad3d,
|
||||
cleanup,
|
||||
loading: opts.loading ?? false,
|
||||
loadingMessage: opts.loadingMessage ?? '',
|
||||
onModelDrop: opts.onModelDrop,
|
||||
isPreview: opts.isPreview ?? false
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, initializeLoad3d, cleanup }
|
||||
}
|
||||
|
||||
describe('Load3DScene', () => {
|
||||
beforeEach(() => {
|
||||
dragState.isDragging = ref(false)
|
||||
dragState.dragMessage = ref('')
|
||||
dragState.handleDragOver.mockReset()
|
||||
dragState.handleDragLeave.mockReset()
|
||||
dragState.handleDrop.mockReset()
|
||||
dragState.capturedOptions = null
|
||||
})
|
||||
|
||||
it('renders the loading overlay child', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards loading + loadingMessage props to the overlay', () => {
|
||||
renderComponent({ loading: true, loadingMessage: 'Loading model…' })
|
||||
|
||||
expect(screen.getByText('Loading model…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls initializeLoad3d with the container element on mount', async () => {
|
||||
const initializeLoad3d = vi.fn().mockResolvedValue(undefined)
|
||||
renderComponent({ initializeLoad3d })
|
||||
|
||||
expect(initializeLoad3d).toHaveBeenCalledOnce()
|
||||
expect(initializeLoad3d.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
|
||||
})
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const cleanup = vi.fn()
|
||||
const { unmount } = renderComponent({ cleanup })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not render the drag overlay when not dragging', () => {
|
||||
dragState.isDragging!.value = false
|
||||
dragState.dragMessage!.value = 'Drop'
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Drop')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the drag overlay with the drag message while dragging in non-preview mode', () => {
|
||||
dragState.isDragging!.value = true
|
||||
dragState.dragMessage!.value = 'Drop to load model'
|
||||
renderComponent({ isPreview: false })
|
||||
|
||||
expect(screen.getByText('Drop to load model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the drag overlay even while dragging when in preview mode', () => {
|
||||
dragState.isDragging!.value = true
|
||||
dragState.dragMessage!.value = 'Drop to load model'
|
||||
renderComponent({ isPreview: true })
|
||||
|
||||
expect(screen.queryByText('Drop to load model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards a dropped file through useLoad3dDrag to the onModelDrop prop', async () => {
|
||||
const onModelDrop = vi.fn()
|
||||
renderComponent({ onModelDrop })
|
||||
|
||||
const file = new File(['m'], 'model.glb')
|
||||
await dragState.capturedOptions!.onModelDrop!(file)
|
||||
|
||||
expect(onModelDrop).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('does not throw when a file is dropped without an onModelDrop handler', async () => {
|
||||
renderComponent({ onModelDrop: undefined })
|
||||
|
||||
const file = new File(['m'], 'model.glb')
|
||||
await expect(
|
||||
dragState.capturedOptions!.onModelDrop!(file)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
class NoopMutationObserver {
|
||||
observe() {}
|
||||
@@ -129,7 +130,7 @@ type RenderOptions = {
|
||||
dragOverrides?: Partial<ReturnType<typeof buildDragStub>>
|
||||
}
|
||||
|
||||
const MOCK_NODE = { id: 'node-1', type: 'Load3D' } as unknown as LGraphNode
|
||||
const MOCK_NODE = createMockLGraphNode({ id: 'node-1', type: 'Load3D' })
|
||||
|
||||
async function renderViewerContent(options: RenderOptions = {}) {
|
||||
const viewerStub = buildViewerStub()
|
||||
|
||||
205
src/components/load3d/controls/AnimationControls.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { playPause: 'Play / pause' } } }
|
||||
})
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
type RenderOpts = {
|
||||
animations?: Animation[]
|
||||
playing?: boolean
|
||||
selectedSpeed?: number
|
||||
selectedAnimation?: number
|
||||
animationProgress?: number
|
||||
animationDuration?: number
|
||||
onSeek?: (progress: number) => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const animations = ref<Animation[]>(opts.animations ?? [])
|
||||
const playing = ref<boolean>(opts.playing ?? false)
|
||||
const selectedSpeed = ref<number>(opts.selectedSpeed ?? 1)
|
||||
const selectedAnimation = ref<number>(opts.selectedAnimation ?? 0)
|
||||
const animationProgress = ref<number>(opts.animationProgress ?? 0)
|
||||
const animationDuration = ref<number>(opts.animationDuration ?? 10)
|
||||
|
||||
const utils = render(AnimationControls, {
|
||||
props: {
|
||||
animations: animations.value,
|
||||
'onUpdate:animations': (v: Animation[] | undefined) => {
|
||||
if (v) animations.value = v
|
||||
},
|
||||
playing: playing.value,
|
||||
'onUpdate:playing': (v: boolean | undefined) => {
|
||||
if (v !== undefined) playing.value = v
|
||||
},
|
||||
selectedSpeed: selectedSpeed.value,
|
||||
'onUpdate:selectedSpeed': (v: number | undefined) => {
|
||||
if (v !== undefined) selectedSpeed.value = v
|
||||
},
|
||||
selectedAnimation: selectedAnimation.value,
|
||||
'onUpdate:selectedAnimation': (v: number | undefined) => {
|
||||
if (v !== undefined) selectedAnimation.value = v
|
||||
},
|
||||
animationProgress: animationProgress.value,
|
||||
'onUpdate:animationProgress': (v: number | undefined) => {
|
||||
if (v !== undefined) animationProgress.value = v
|
||||
},
|
||||
animationDuration: animationDuration.value,
|
||||
'onUpdate:animationDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) animationDuration.value = v
|
||||
},
|
||||
onSeek: opts.onSeek
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('AnimationControls', () => {
|
||||
it('renders nothing when the animation list is empty', () => {
|
||||
renderComponent({ animations: [] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Play / pause' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the play / speed / track / progress widgets when animations are present', () => {
|
||||
renderComponent({
|
||||
animations: [
|
||||
{ name: 'idle', index: 0 },
|
||||
{ name: 'walk', index: 1 }
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Play / pause' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2)
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flips playing to true via v-model when starting from a paused state', async () => {
|
||||
const { user, playing } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
playing: false
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
|
||||
|
||||
expect(playing.value).toBe(true)
|
||||
})
|
||||
|
||||
it('flips playing to false via v-model when starting from a playing state', async () => {
|
||||
const { user, playing } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
playing: true
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
|
||||
|
||||
expect(playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates animationProgress and emits seek with the new progress when the slider moves', () => {
|
||||
const onSeek = vi.fn()
|
||||
const { animationProgress } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationProgress: 0,
|
||||
onSeek
|
||||
})
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '37.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(animationProgress.value).toBe(37.5)
|
||||
expect(onSeek).toHaveBeenCalledWith(37.5)
|
||||
})
|
||||
|
||||
it('formats the time display under one minute as Ns', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 30,
|
||||
animationProgress: 50 // half of 30s = 15s
|
||||
})
|
||||
|
||||
expect(screen.getByText('15.0s / 30.0s')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats the time display over one minute as M:SS.S', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 90,
|
||||
animationProgress: 50 // half of 90s = 45s, total 1:30.0
|
||||
})
|
||||
|
||||
expect(screen.getByText('45.0s / 1:30.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows 0s for currentTime when animationDuration is 0', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 0,
|
||||
animationProgress: 50
|
||||
})
|
||||
|
||||
expect(screen.getByText('0.0s / 0.0s')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
84
src/components/load3d/controls/CameraControls.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
|
||||
default: {
|
||||
name: 'PopupSlider',
|
||||
props: ['tooltipText', 'modelValue'],
|
||||
template: '<div data-testid="popup-slider">{{ tooltipText }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { load3d: { switchCamera: 'Switch camera', fov: 'FOV' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
|
||||
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
|
||||
const fov = ref<number>(initial.fov ?? 75)
|
||||
|
||||
const utils = render(CameraControls, {
|
||||
props: {
|
||||
cameraType: cameraType.value,
|
||||
'onUpdate:cameraType': (v: CameraType | undefined) => {
|
||||
if (v) cameraType.value = v
|
||||
},
|
||||
fov: fov.value,
|
||||
'onUpdate:fov': (v: number | undefined) => {
|
||||
if (v !== undefined) fov.value = v
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, cameraType, fov, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('CameraControls', () => {
|
||||
it('renders the switch-camera button', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Switch camera' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the FOV PopupSlider only for the perspective camera', () => {
|
||||
renderComponent({ type: 'perspective' })
|
||||
expect(screen.getByTestId('popup-slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the FOV PopupSlider for the orthographic camera', () => {
|
||||
renderComponent({ type: 'orthographic' })
|
||||
expect(screen.queryByTestId('popup-slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles cameraType from perspective to orthographic when the button is clicked', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'perspective' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
|
||||
|
||||
expect(cameraType.value).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('toggles cameraType from orthographic to perspective when the button is clicked', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'orthographic' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
|
||||
|
||||
expect(cameraType.value).toBe('perspective')
|
||||
})
|
||||
})
|
||||