Compare commits

..

5 Commits

Author SHA1 Message Date
dante01yoon
e994c2f630 Revert "fix: update knip package entrypoints"
This reverts commit 711fa84e3c.
2026-04-28 16:23:08 +09:00
dante01yoon
711fa84e3c fix: update knip package entrypoints 2026-04-28 16:00:51 +09:00
dante01yoon
51874a57eb refactor: address promotion policy review 2026-04-28 15:57:27 +09:00
Alexander Brown
5782131a18 Merge branch 'main' into refactor/extract-widget-classification 2026-04-27 19:17:49 -07:00
dante01yoon
87bcff999c refactor: extract widget classification from promotionUtils
Move widget classification logic (isPreviewPseudoWidget, isRecommendedWidget,
getPromotableWidgets) into dedicated widgetClassification module. Add missing
isRecommendedWidget tests. No behavior change.
2026-04-22 06:38:53 +09:00
322 changed files with 4237 additions and 17154 deletions

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -1,156 +0,0 @@
---
name: reviewing-unit-tests
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
---
# Reviewing Unit Tests for ComfyUI_frontend
## Overview
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
## Review Workflow
1. Identify the test type: component, store, composable, util, or bugfix regression.
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
3. Open the authoritative doc section before judging structure.
4. Scan the red flags below.
5. State the verdict first. Name the failure mode. Cite the doc or rule.
## Source of Truth / Precedence
When docs and examples conflict, use this order:
1. Explicit repo rules, lint rules, and note blocks.
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
4. Example snippets
5. Prior diffs
Apply these repo-specific clarifications:
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
- Motion != fix.
## 30-Second Red Flags
| If you see... | Failure mode | Default action |
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
## Rationalization Table
| Excuse | Reality |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
| "It is only a nit" | Explicit repo-rule violations are never nits. |
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
## Mocking Rules
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
### Alias-by-Renaming
```ts
// Before
const mockAdd = vi.hoisted(() => vi.fn())
// After: same indirection, new name
function getToastAddMock() {
return useToast().add
}
```
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
### `vi.mocked()` Scope
| Use case | `vi.mocked()` required? |
| --------------------------------------------------------------- | ----------------------- |
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
| `.mock.calls`, `.mock.results` | Yes |
| `expect(fn).toHaveBeenCalled()` | No |
| `expect(fn).toHaveBeenCalledWith(...)` | No |
- Flag casts whenever `vi.mocked()` would narrow correctly.
- Do not add `vi.mocked()` around assertion-only references just for style.
### Reset Hygiene
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
### Third-Party Seams
- Distinguish trivial hooks from behavior-rich APIs.
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
- That exception does not justify mocking behavior-rich third-party modules.
### `vue-i18n`
- Never mock `vue-i18n` in component tests.
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
## Test-Body Rules
| Smell | Review bar |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Change-detector test | Reject. Default values alone prove nothing. |
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
## Bugfix Regression Proof
For `fix:` PRs or bugfix diffs:
1. Identify the production change that fixes the bug.
2. Verify the new test fails on pre-fix code, or ask the author to show it.
3. If the test passes on broken code, request changes.
A regression test that never proves red does not pin the bug.
## Review Output Rules
- State verdict before procedural questions.
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
- Link the authoritative doc section in the review comment.
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
## Quick Reference
| When you see... | Read this |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
## Key Files to Read
| Purpose | Path |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

@@ -45,8 +45,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests

View File

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

View File

@@ -18,7 +18,6 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
@@ -87,8 +86,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
@@ -140,10 +137,7 @@ jobs:
name: 'Update Website Screenshots'
})
} catch (e) {
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
// Label may already be removed
}
post-starting-comment:

View File

@@ -1,13 +1,5 @@
#!/usr/bin/env bash
# Skip in CI: the canonical knip check runs in ci-lint-format on every
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
# setup-comfyui-server, which contaminates knip's project glob with the
# devtools copy under custom_nodes and produces false-positive failures.
if [ -n "${CI-}" ]; then
exit 0
fi
# Run Knip with cache via package script
pnpm knip 1>&2

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-700);
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-600);
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-500);
background-color: var(--color-coral-red-400);
}
.task-div .p-card {

View File

@@ -113,31 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
```html
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
```
The localized `/zh-CN/contact` page uses the same portal and script with form
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
renders the documented embed container.
## Scripts
- `pnpm dev` — Astro dev server

View File

@@ -178,43 +178,3 @@ test.describe('Get started section links @smoke', () => {
await expect(cloudLink).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})
test.describe('Attribution preservation @smoke', () => {
test('decorates owned Comfy links with ad attribution', async ({ page }) => {
await page.goto(
'/?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123'
)
await expect(
page
.getByTestId('desktop-nav-cta')
.locator(
'a[href="https://cloud.comfy.org/?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123"]'
)
).toBeVisible()
const productCardsSection = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
await expect(
productCardsSection.locator(
'a[href="/cloud?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123"]'
)
).toBeVisible()
})
test('uses stored attribution after same-origin navigation', async ({
page
}) => {
await page.goto('/?utm_source=google&utm_medium=cpc')
await page.goto('/cloud')
const cloudCta = page.getByRole('link', {
name: /TRY COMFY CLOUD FOR FREE/i
})
await expect(cloudCta).toHaveAttribute(
'href',
'https://cloud.comfy.org/?utm_source=google&utm_medium=cpc'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -26,7 +26,6 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import SectionLabel from '../common/SectionLabel.vue'
import HubspotFormEmbed from './HubspotFormEmbed.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
@@ -16,6 +17,30 @@ function tk(suffix: string): TranslationKey {
return `contact.form.${suffix}` as TranslationKey
}
const firstName = ref('')
const lastName = ref('')
const company = ref('')
const phone = ref('')
const selectedPackage = ref('')
const comfyUsage = ref('')
const lookingFor = ref('')
const packageOptions = [
'packageIndividual',
'packageTeams',
'packageEnterprise'
] as const
const usageOptions = [
'usingYesProduction',
'usingYesTesting',
'usingNotYet',
'usingOtherTools'
] as const
const inputClass =
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
const sectionRef = ref<HTMLElement>()
const badgeRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
@@ -30,6 +55,10 @@ useHeroAnimation({
video: formRef,
parallax: false
})
function handleSubmit() {
// TODO: implement form submission
}
</script>
<template>
@@ -76,7 +105,160 @@ useHeroAnimation({
<!-- Right column: form -->
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
<HubspotFormEmbed :locale />
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- First Name + Last Name -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('firstName'), locale) }}*
</label>
<input
v-model="firstName"
type="text"
required
:placeholder="t(tk('firstNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lastName'), locale) }}*
</label>
<input
v-model="lastName"
type="text"
required
:placeholder="t(tk('lastNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
</div>
<!-- Company + Phone -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('company'), locale) }}*
</label>
<input
v-model="company"
type="text"
required
:placeholder="t(tk('companyPlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('phone'), locale) }}
</label>
<input v-model="phone" type="tel" :class="inputClass" />
</div>
</div>
<!-- Package selection -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('packageQuestion'), locale) }}
</p>
<div class="mt-3 flex flex-wrap gap-3">
<label
v-for="opt in packageOptions"
:key="opt"
:class="
cn(
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
selectedPackage === opt
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
)
"
>
<input
v-model="selectedPackage"
type="radio"
name="package"
:value="opt"
class="sr-only"
/>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
selectedPackage === opt
? 'border-primary-comfy-yellow'
: 'border-primary-warm-gray/40'
)
"
>
<span
v-if="selectedPackage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
{{ t(tk(opt), locale) }}
</label>
</div>
</div>
<!-- Comfy usage -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('usingComfy'), locale) }}
</p>
<div class="mt-3 space-y-3">
<label
v-for="opt in usageOptions"
:key="opt"
class="flex cursor-pointer items-center gap-3"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
comfyUsage === opt
? 'border-primary-comfy-yellow'
: 'border-(--site-border-subtle)'
)
"
>
<span
v-if="comfyUsage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
<input
v-model="comfyUsage"
type="radio"
:value="opt"
class="sr-only"
/>
<span class="text-primary-comfy-canvas text-sm">
{{ t(tk(opt), locale) }}
</span>
</label>
</div>
</div>
<!-- Looking for -->
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lookingFor'), locale) }}
</label>
<textarea
v-model="lookingFor"
:placeholder="t(tk('lookingForPlaceholder'), locale)"
:class="cn(inputClass, 'min-h-24 resize-y')"
/>
</div>
<!-- Submit -->
<div>
<BrandButton type="submit" variant="outline" size="sm">
{{ t(tk('submit'), locale) }}
</BrandButton>
</div>
</form>
</div>
</section>
</template>

View File

@@ -1,126 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
const HUBSPOT_CONTACT_PORTAL_ID = '244637579'
const HUBSPOT_CONTACT_REGION = 'na2'
const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed'
const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js`
const hubspotContactFormIds: Record<Locale, string> = {
en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262',
'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93'
}
const hasEmbedLoadError = ref(false)
const hubspotContactFormId = computed(() => hubspotContactFormIds[locale])
const hubspotFormStyles: Record<`--${string}`, string> = {
'--hsf-global__font-family': "'PP Formula', sans-serif",
'--hsf-global__color': '#c2bfb9',
'--hsf-background__background-color': '#211927',
'--hsf-background__border-width': '0',
'--hsf-background__padding': '0',
'--hsf-button__font-family': "'PP Formula', sans-serif",
'--hsf-button__font-size': '14px',
'--hsf-button__color': '#211927',
'--hsf-button__background-color': '#f2ff59',
'--hsf-button__border-radius': '16px',
'--hsf-button__padding': '10px 24px',
'--hsf-richtext__font-family': "'PP Formula', sans-serif",
'--hsf-richtext__color': '#c2bfb9',
'--hsf-heading__font-family': "'PP Formula', sans-serif",
'--hsf-heading__color': '#c2bfb9',
'--hsf-field-label__font-family': "'PP Formula', sans-serif",
'--hsf-field-label__font-size': '12px',
'--hsf-field-label__color': '#c2bfb9',
'--hsf-field-description__font-family': "'PP Formula', sans-serif",
'--hsf-field-description__color': '#c2bfb9',
'--hsf-field-footer__font-family': "'PP Formula', sans-serif",
'--hsf-field-footer__color': '#c2bfb9',
'--hsf-field-input__font-family': "'PP Formula', sans-serif",
'--hsf-field-input__color': '#c2bfb9',
'--hsf-field-input__background-color': '#2a2230',
'--hsf-field-input__placeholder-color': '#585159',
'--hsf-field-input__border-color': '#3b3539',
'--hsf-field-input__border-width': '1px',
'--hsf-field-input__border-style': 'solid',
'--hsf-field-input__border-radius': '16px',
'--hsf-field-input__padding': '16px',
'--hsf-field-textarea__font-family': "'PP Formula', sans-serif",
'--hsf-field-textarea__color': '#c2bfb9',
'--hsf-field-textarea__background-color': '#2a2230',
'--hsf-field-textarea__placeholder-color': '#585159',
'--hsf-field-textarea__border-color': '#3b3539',
'--hsf-field-textarea__border-width': '1px',
'--hsf-field-textarea__border-style': 'solid',
'--hsf-field-textarea__border-radius': '16px',
'--hsf-field-textarea__padding': '16px',
'--hsf-field-checkbox__color': '#c2bfb9',
'--hsf-field-checkbox__background-color': '#2a2230',
'--hsf-field-checkbox__border-color': '#464147',
'--hsf-field-checkbox__border-width': '1px',
'--hsf-field-checkbox__border-style': 'solid',
'--hsf-field-radio__color': '#c2bfb9',
'--hsf-field-radio__background-color': '#2a2230',
'--hsf-field-radio__border-color': '#464147',
'--hsf-field-radio__border-width': '1px',
'--hsf-field-radio__border-style': 'solid',
'--hsf-erroralert__font-family': "'PP Formula', sans-serif",
'--hsf-infoalert__font-family': "'PP Formula', sans-serif"
}
onMounted(() => {
if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return
const script = document.createElement('script')
script.id = HUBSPOT_CONTACT_SCRIPT_ID
script.src = HUBSPOT_CONTACT_SCRIPT_SRC
script.defer = true
script.addEventListener(
'error',
() => {
hasEmbedLoadError.value = true
script.remove()
},
{ once: true }
)
document.head.append(script)
})
</script>
<template>
<div class="min-h-[640px] w-full">
<p
v-if="hasEmbedLoadError"
class="text-primary-comfy-canvas text-sm/6"
role="status"
>
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
<a
class="text-primary-comfy-yellow underline"
href="mailto:hello@comfy.org"
>
hello@comfy.org
</a>
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
</p>
<div
v-else
:key="hubspotContactFormId"
class="hs-form-html"
:style="hubspotFormStyles"
:data-region="HUBSPOT_CONTACT_REGION"
:data-form-id="hubspotContactFormId"
:data-portal-id="HUBSPOT_CONTACT_PORTAL_ID"
/>
</div>
</template>

View File

@@ -3298,13 +3298,82 @@ const translations = {
en: 'Find your answer here',
'zh-CN': '在这里找到答案'
},
'contact.form.embedLoadErrorPrefix': {
en: 'Unable to load the contact form. Email us at',
'zh-CN': '联系表单无法加载。请发送邮件至'
'contact.form.firstName': {
en: 'First name',
'zh-CN': ''
},
'contact.form.embedLoadErrorSuffix': {
en: "and we'll route your request.",
'zh-CN': '我们会为您处理请求。'
'contact.form.lastName': {
en: 'Last Name',
'zh-CN': ''
},
'contact.form.company': {
en: 'Company',
'zh-CN': '公司'
},
'contact.form.phone': {
en: 'Phone Number (optional)',
'zh-CN': '电话号码(可选)'
},
'contact.form.packageQuestion': {
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
'zh-CN':
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K、个人套餐或团队套餐'
},
'contact.form.packageIndividual': {
en: 'INDIVIDUAL',
'zh-CN': '个人'
},
'contact.form.packageTeams': {
en: 'TEAMS',
'zh-CN': '团队'
},
'contact.form.packageEnterprise': {
en: 'ENTERPRISE',
'zh-CN': '企业'
},
'contact.form.usingComfy': {
en: 'Are you /your team currently using Comfy?',
'zh-CN': '您/您的团队目前是否在使用 Comfy'
},
'contact.form.usingYesProduction': {
en: 'Yes, in production',
'zh-CN': '是,在生产环境中'
},
'contact.form.usingYesTesting': {
en: 'Yes, testing / experimenting',
'zh-CN': '是,测试/实验中'
},
'contact.form.usingNotYet': {
en: 'Not yet, evaluating',
'zh-CN': '尚未使用,评估中'
},
'contact.form.usingOtherTools': {
en: 'Not using Comfy yet, but using other GenAI tools',
'zh-CN': '尚未使用 Comfy但在使用其他 GenAI 工具'
},
'contact.form.lookingFor': {
en: 'What are you looking for?',
'zh-CN': '您在寻找什么?'
},
'contact.form.lookingForPlaceholder': {
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
},
'contact.form.submit': {
en: 'SUBMIT',
'zh-CN': '提交'
},
'contact.form.firstNamePlaceholder': {
en: 'Jane',
'zh-CN': 'Jane'
},
'contact.form.lastNamePlaceholder': {
en: 'Smith',
'zh-CN': 'Smith'
},
'contact.form.companyPlaceholder': {
en: 'jane@acme.org',
'zh-CN': 'jane@acme.org'
},
'customers.story.whatsNext': {

View File

@@ -133,16 +133,8 @@ const websiteJsonLd = {
<script>
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
import { ScrollTrigger } from '../scripts/gsapSetup'
import { initAttributionPersistence } from '../scripts/attribution'
import { initPostHog, capturePageview } from '../scripts/posthog'
initSmoothScroll()
initAttributionPersistence()
if (import.meta.env.PROD) {
initPostHog()
document.addEventListener('astro:page-load', capturePageview)
}
document.addEventListener('astro:page-load', () => {
ScrollTrigger.refresh()

View File

@@ -1,108 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
readAttributionFromSearch,
withPreservedAttribution
} from './attribution'
describe('readAttributionFromSearch', () => {
it('reads UTM parameters and ad click IDs', () => {
const attribution = readAttributionFromSearch(
'?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=123&fbclid=456&ignored=value'
)
expect(attribution).toEqual({
utm_source: 'google',
utm_medium: 'cpc',
utm_campaign: 'spring',
gclid: '123',
fbclid: '456'
})
})
it('ignores empty attribution values', () => {
const attribution = readAttributionFromSearch(
'?utm_source=&utm_medium=cpc&gclid='
)
expect(attribution).toEqual({
utm_medium: 'cpc'
})
})
})
describe('withPreservedAttribution', () => {
const attribution = {
utm_source: 'google',
utm_medium: 'cpc',
gclid: 'abc123'
}
it('adds attribution to Cloud links', () => {
expect(
withPreservedAttribution(
'https://cloud.comfy.org',
attribution,
'https://comfy.org'
)
).toBe(
'https://cloud.comfy.org/?utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('keeps checkout parameters while replacing hardcoded attribution', () => {
expect(
withPreservedAttribution(
'https://cloud.comfy.org/cloud/subscribe?tier=creator&cycle=monthly&utm_source=workflow_hub',
attribution,
'https://comfy.org'
)
).toBe(
'https://cloud.comfy.org/cloud/subscribe?tier=creator&cycle=monthly&utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('keeps relative links relative', () => {
expect(
withPreservedAttribution(
'/cloud#pricing',
attribution,
'https://comfy.org'
)
).toBe('/cloud?utm_source=google&utm_medium=cpc&gclid=abc123#pricing')
})
it('preserves attribution on the workflows app route', () => {
expect(
withPreservedAttribution(
'https://comfy.org/workflows',
attribution,
'https://comfy.org'
)
).toBe(
'https://comfy.org/workflows?utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('adds attribution to platform links', () => {
expect(
withPreservedAttribution(
'https://platform.comfy.org/profile/api-keys',
attribution,
'https://comfy.org'
)
).toBe(
'https://platform.comfy.org/profile/api-keys?utm_source=google&utm_medium=cpc&gclid=abc123'
)
})
it('does not add attribution to unrelated external destinations', () => {
expect(
withPreservedAttribution(
'https://docs.comfy.org/api-reference/cloud',
attribution,
'https://comfy.org'
)
).toBe('https://docs.comfy.org/api-reference/cloud')
})
})

View File

@@ -1,214 +0,0 @@
const ATTRIBUTION_STORAGE_KEY = 'comfy_website_attribution'
const CLICK_ID_QUERY_KEYS = [
'im_ref',
'gclid',
'gbraid',
'wbraid',
'fbclid',
'msclkid',
'ttclid',
'li_fat_id'
] as const
const CLICK_ID_QUERY_KEY_SET = new Set<string>(CLICK_ID_QUERY_KEYS)
const ATTRIBUTION_HOSTNAMES = new Set([
'comfy.org',
'www.comfy.org',
'cloud.comfy.org',
'platform.comfy.org'
])
type AttributionParams = Record<string, string>
let attributionPersistenceInitialized = false
function isAttributionQueryKey(key: string): boolean {
return key.startsWith('utm_') || CLICK_ID_QUERY_KEY_SET.has(key)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasAttribution(params: AttributionParams): boolean {
return Object.keys(params).length > 0
}
export function readAttributionFromSearch(search: string): AttributionParams {
const params = new URLSearchParams(search)
const attribution: AttributionParams = {}
for (const [key, value] of params) {
if (value && isAttributionQueryKey(key)) {
attribution[key] = value
}
}
return attribution
}
function readStoredAttribution(storage: Storage): AttributionParams {
try {
const stored = storage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isRecord(parsed)) return {}
const attribution: AttributionParams = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string' && value && isAttributionQueryKey(key)) {
attribution[key] = value
}
}
return attribution
} catch {
return {}
}
}
function persistAttribution(
storage: Storage,
attribution: AttributionParams
): void {
try {
storage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution))
} catch {
return
}
}
function resolveAttribution(
storage: Storage,
search: string
): AttributionParams {
const stored = readStoredAttribution(storage)
const fromUrl = readAttributionFromSearch(search)
const attribution = { ...stored, ...fromUrl }
if (hasAttribution(fromUrl)) {
persistAttribution(storage, attribution)
}
return attribution
}
function shouldDecorateUrl(url: URL, currentOrigin: string): boolean {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false
}
if (url.origin === currentOrigin) {
return true
}
return ATTRIBUTION_HOSTNAMES.has(url.hostname.toLowerCase())
}
function isRelativeHref(href: string): boolean {
return href.startsWith('/') || href.startsWith('./') || href.startsWith('../')
}
export function withPreservedAttribution(
href: string,
attribution: AttributionParams,
currentOrigin: string
): string {
if (!hasAttribution(attribution) || href.startsWith('#')) {
return href
}
let url: URL
try {
url = new URL(href, currentOrigin)
} catch {
return href
}
if (!shouldDecorateUrl(url, currentOrigin)) {
return href
}
for (const [key, value] of Object.entries(attribution)) {
url.searchParams.set(key, value)
}
if (isRelativeHref(href)) {
return `${url.pathname}${url.search}${url.hash}`
}
return url.href
}
function decorateLinks(attribution: AttributionParams): void {
const { origin } = window.location
for (const link of document.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = link.getAttribute('href')
if (!href) continue
const decoratedHref = withPreservedAttribution(href, attribution, origin)
if (decoratedHref !== href) {
link.setAttribute('href', decoratedHref)
}
}
}
export function initAttributionPersistence(): void {
if (typeof window === 'undefined' || attributionPersistenceInitialized) return
attributionPersistenceInitialized = true
let scheduled = false
const refreshLinks = () => {
scheduled = false
const attribution = resolveAttribution(
window.sessionStorage,
window.location.search
)
decorateLinks(attribution)
}
const scheduleRefresh = () => {
if (scheduled) return
scheduled = true
window.queueMicrotask(refreshLinks)
}
const observer = new MutationObserver(scheduleRefresh)
document.addEventListener('astro:page-load', refreshLinks)
document.addEventListener(
'click',
(event) => {
const target = event.target
if (!(target instanceof Element)) return
const link = target.closest<HTMLAnchorElement>('a[href]')
if (!link) return
const href = link.getAttribute('href')
if (!href) return
const attribution = resolveAttribution(
window.sessionStorage,
window.location.search
)
link.setAttribute(
'href',
withPreservedAttribution(href, attribution, window.location.origin)
)
},
{ capture: true }
)
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['href']
})
refreshLinks()
}

View File

@@ -1,36 +0,0 @@
import posthog from 'posthog-js'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
const POSTHOG_API_HOST =
import.meta.env.PUBLIC_POSTHOG_API_HOST ?? 'https://t.comfy.org'
const POSTHOG_UI_HOST =
import.meta.env.PUBLIC_POSTHOG_UI_HOST ?? 'https://us.posthog.com'
let initialized = false
export function initPostHog() {
if (initialized || typeof window === 'undefined' || !POSTHOG_KEY) return
try {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_API_HOST,
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
})
initialized = true
} catch (error) {
console.error('PostHog init failed', error)
}
}
export function capturePageview() {
if (!initialized) return
try {
posthog.capture('$pageview')
} catch (error) {
console.error('PostHog pageview capture failed', error)
}
}

View File

@@ -1,36 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
else process.env.WEBSITE_GITHUB_STARS_OVERRIDE = savedOverride
})
it('uses the build-time override without calling GitHub', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
})
describe('formatStarCount', () => {
it('formats the visual-test override to match committed snapshots', () => {
expect(formatStarCount(110000)).toBe('110K')
})
})

View File

@@ -2,9 +2,6 @@ export async function fetchGitHubStars(
owner: string,
repo: string
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
@@ -28,17 +25,3 @@ export function formatStarCount(count: number): string {
}
return count.toString()
}
function readGitHubStarsOverride(): number | undefined {
const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
if (rawCount === undefined || rawCount === '') return undefined
const count = Number(rawCount)
if (!Number.isSafeInteger(count) || count < 0) {
throw new Error(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
}
return count
}

View File

@@ -15,15 +15,11 @@ browser_tests/
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
│ ├── components/ - Page object classes (locators, user interactions)
│ │ ├── Actionbar.ts
│ ├── components/ - Page object components (locators, user interactions)
│ │ ├── ContextMenu.ts
│ │ ├── ManageGroupNode.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ ── Templates.ts
│ │ ├── Topbar.ts
│ │ └── ...
│ │ ── Topbar.ts
│ ├── helpers/ - Focused helper classes (domain-specific actions)
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
@@ -32,36 +28,17 @@ browser_tests/
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
│ ├── builderTestUtils.ts
│ ├── clipboardSpy.ts
│ ├── fitToView.ts
│ ├── perfReporter.ts
│ └── ...
│ └── utils/ - Pure utility functions (no page dependency)
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
### Placement Rule
When adding a new file, use this decision tree:
```mermaid
flowchart TD
A[New file in browser_tests/fixtures/] --> B{Has any code?}
B -- No, JSON/data only --> D[fixtures/data/]
B -- Yes --> C{Is it a class?}
C -- No, exported functions --> U[fixtures/utils/]
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
E -- Yes --> P[fixtures/components/]
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
```
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
## Page Object Locator Style

View File

@@ -140,9 +140,12 @@ Always check for existing helpers and fixtures before implementing new ones:
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
- **ComfyMouse**: Helper for precise mouse operations on the canvas
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
- `actionbar.ts`: Interact with the action bar
- `manageGroupNode.ts`: Group node management operations
- `templates.ts`: Template workflows operations
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.

View File

@@ -0,0 +1,284 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [210, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [210, 168],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [210, 168],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,4 +1,4 @@
import type { Locator, Mouse } from '@playwright/test'
import type { Mouse } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
@@ -72,22 +72,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.nextFrame()
}
async resizeByDragging(
element: Locator,
{ x, y }: { x?: number; y?: number }
) {
const elementBox = await element.boundingBox()
if (!elementBox) throw new Error('element should have layout')
const cx = elementBox.x + elementBox.width / 2
const cy = elementBox.y + elementBox.height / 2
await this.dragAndDrop(
{ x: cx, y: cy },
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
)
}
//#region Pass-through
async click(...args: Parameters<Mouse['click']>) {
return await this.mouse.click(...args)

View File

@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
import { ComfyActionbar } from '@e2e/helpers/actionbar'
import { ComfyTemplates } from '@e2e/helpers/templates'
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
@@ -22,7 +22,6 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import {
AssetsSidebarTab,
ModelLibrarySidebarTab,
@@ -55,13 +54,11 @@ class ComfyPropertiesPanel {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
}
}
@@ -163,7 +160,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly titleEditor: TitleEditor
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
@@ -210,14 +206,13 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.titleEditor = new TitleEditor(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)

View File

@@ -1,12 +1,6 @@
import type { Locator } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
const { searchBoxV2 } = TestIds
export type { RootCategoryId }
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
@@ -14,99 +8,24 @@ export class ComfyNodeSearchBoxV2 {
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
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
constructor(readonly page: Page) {
this.dialog = page.getByRole('search')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
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
)
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
}
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
categoryButton(categoryId: string): Locator {
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
return this.dialog.getByTestId(`category-${categoryId}`)
}
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
rootCategoryButton(id: RootCategoryId): Locator {
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
filterBarButton(name: string): Locator {
return this.dialog.getByRole('button', { name })
}
/** Top filter-bar input/output type popover trigger. */
typeFilterButton(key: 'input' | 'output'): Locator {
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
}
async applyTypeFilter(
key: 'input' | 'output',
typeName: string
): Promise<void> {
const trigger = this.typeFilterButton(key)
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'visible' })
await this.filterSearch.fill(typeName)
await this.filterOptions.filter({ hasText: typeName }).first().click()
// The popover does not auto-close on selection — toggle the trigger.
await trigger.click()
await this.filterOptions.first().waitFor({ state: 'hidden' })
}
async removeFilterChip(index = 0): Promise<void> {
await this.filterChips
.nth(index)
.getByTestId(searchBoxV2.chipDelete)
.click()
}
async toggle(): Promise<void> {
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
}
async open(): Promise<void> {
if (await this.input.isVisible()) return
await this.toggle()
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
await this.comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
}
async setup(): Promise<void> {
await this.ensureV2Search()
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await this.comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
}
}

View File

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

View File

@@ -1,33 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The node/group title-editing input. Rendered in three scopes: the canvas
* overlay (page-wide), the properties panel, and the Vue node itself.
*/
export class TitleEditor {
public readonly input: Locator
constructor(scope: Page | Locator) {
this.input = scope.getByTestId(TestIds.node.titleInput)
}
async setTitle(title: string): Promise<void> {
await this.input.fill(title)
await this.input.press('Enter')
}
async cancel(): Promise<void> {
await this.input.press('Escape')
}
async expectVisible(): Promise<void> {
await expect(this.input).toBeVisible()
}
async expectHidden(): Promise<void> {
await expect(this.input).toBeHidden()
}
}

View File

@@ -1,54 +0,0 @@
import type { Locator } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
class BoundingBoxCoordinate {
public readonly root: Locator
public readonly input: Locator
public readonly incrementButton: Locator
public readonly decrementButton: Locator
constructor(root: Locator) {
this.root = root
this.input = root.locator('input')
this.incrementButton = root.getByTestId(TestIds.widgets.increment)
this.decrementButton = root.getByTestId(TestIds.widgets.decrement)
}
async type(value: string | number): Promise<void> {
await this.input.fill(String(value))
await this.input.press('Enter')
}
async focus(): Promise<void> {
await this.input.focus()
}
async increment(): Promise<void> {
await this.incrementButton.click()
}
async decrement(): Promise<void> {
await this.decrementButton.click()
}
}
export class WidgetBoundingBoxFixture {
public readonly root: Locator
public readonly x: BoundingBoxCoordinate
public readonly y: BoundingBoxCoordinate
public readonly width: BoundingBoxCoordinate
public readonly height: BoundingBoxCoordinate
constructor(parent: Locator) {
this.root = parent.getByTestId('bounding-box')
this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x'))
this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y'))
this.width = new BoundingBoxCoordinate(
this.root.getByTestId('bounding-box-width')
)
this.height = new BoundingBoxCoordinate(
this.root.getByTestId('bounding-box-height')
)
}
}

View File

@@ -74,7 +74,7 @@ export class CanvasHelper {
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
async toAbsolute(position: Position): Promise<Position> {
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
@@ -150,28 +150,6 @@ export class CanvasHelper {
await nextFrame(this.page)
}
async getOffset(): Promise<[number, number]> {
return this.page.evaluate(
() => [...window.app!.canvas.ds.offset] as [number, number]
)
}
async getNodeTitleHeight(): Promise<number> {
return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT)
}
/**
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
* coordinates.
*/
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
await this.page.keyboard.down('Control')
await this.page.keyboard.down('Shift')
await this.dragAndDrop(from, to)
await this.page.keyboard.up('Shift')
await this.page.keyboard.up('Control')
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
@@ -264,39 +242,11 @@ export class CanvasHelper {
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
): Promise<void> {
const { modifiers = [] } = options
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
}
async middleClick(position: Position): Promise<void> {
await this.mouseClickAt(position, { button: 'middle' })
}
async dblclickGroupTitle(title: string): Promise<void> {
const clientPos = await this.page.evaluate((targetTitle) => {
const groups = window.app!.canvas.graph?.groups ?? []
const group = groups.find(
(g: { title: string }) => g.title === targetTitle
)
if (!group) return null
const cx = group.pos[0] + group.size[0] / 2
const cy = group.pos[1] + group.titleHeight / 2
return window.app!.canvasPosToClientPos([cx, cy])
}, title)
if (!clientPos) throw new Error(`Group "${title}" not found`)
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
await nextFrame(this.page)
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {

View File

@@ -4,7 +4,7 @@ import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
export class ClipboardHelper {
constructor(

View File

@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'

View File

@@ -55,32 +55,29 @@ export class NodeOperationsHelper {
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and position is provided, a synthetic MouseEvent is created as the
* dragEvent.
* @param position - When ghost is true, client coordinates for the ghost
* placement dragEvent. Otherwise, world coordinates assigned to node.pos.
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
position?: Position
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, pos]) => {
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && pos) {
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: pos.x,
clientY: pos.y
clientX: cursor.x,
clientY: cursor.y
})
} else if (pos) {
node.pos = [pos.x, pos.y]
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, position ?? null] as const
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}

View File

@@ -86,11 +86,7 @@ export const TestIds = {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button',
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
actionBarButtons: 'action-bar-buttons'
subscribeButton: 'topbar-subscribe-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
@@ -210,8 +206,7 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list'
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
@@ -254,20 +249,6 @@ export const TestIds = {
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
},
searchBoxV2: {
resultItem: 'result-item',
filterOption: 'filter-option',
filterChip: 'filter-chip',
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}`
}
} as const

View File

@@ -1,8 +1,7 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
@@ -170,36 +169,6 @@ class NodeSlotReference {
[this.type, this.node.id, this.index] as const
)
}
async getLink(): Promise<SerialisableLLink | null> {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const graph = window.app!.canvas.graph!
const node = graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const linkId =
type === 'input'
? node.inputs[index].link
: (node.outputs[index].links ?? [])[0]
if (linkId == null) return null
const link =
graph.links instanceof Map
? graph.links.get(linkId)
: graph.links[linkId]
if (!link) return null
return {
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
target_id: link.target_id,
target_slot: link.target_slot,
type: link.type,
parentId: link.parentId
}
},
[this.type, this.node.id, this.index] as const
)
}
}
export class NodeWidgetReference {
@@ -357,23 +326,6 @@ export class NodeReference {
const nodeSize = await this.getSize()
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
}
async dragBy(
delta: Position,
options?: {
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const titlePos = await this.getTitlePosition()
const target = { x: titlePos.x + delta.x, y: titlePos.y + delta.y }
const modifiers = options?.modifiers ?? []
const keyboard = this.comfyPage.page.keyboard
for (const mod of modifiers) await keyboard.down(mod)
try {
await this.comfyPage.canvasOps.dragAndDrop(titlePos, target)
} finally {
for (const mod of modifiers) await keyboard.up(mod)
}
}
async isPinned() {
return !!(await this.getFlags()).pinned
}

View File

@@ -1,13 +1,12 @@
import type { Locator } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
public readonly title: Locator
public readonly titleEditor: TitleEditor
public readonly titleInput: Locator
public readonly body: Locator
public readonly pinIndicator: Locator
public readonly collapseButton: Locator
@@ -17,7 +16,7 @@ export class VueNodeFixture {
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
this.title = locator.getByTestId('node-title')
this.titleEditor = new TitleEditor(locator)
this.titleInput = locator.getByTestId('node-title-input')
this.body = locator.locator('[data-testid^="node-body-"]')
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
this.collapseButton = locator.getByTestId('node-collapse-button')
@@ -31,8 +30,17 @@ export class VueNodeFixture {
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(value)
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.fill(value)
await input.press('Enter')
}
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await input.press('Escape')
}
async toggleCollapse(): Promise<void> {

View File

@@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig'
import { writePerfReport } from '@e2e/fixtures/utils/perfReporter'
import { writePerfReport } from '@e2e/helpers/perfReporter'
import { restorePath } from '@e2e/utils/backupUtils'
dotenvConfig()

View File

@@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
interface BuilderSetupResult {
inputNodeTitle: string

View File

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

View File

@@ -2,7 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
test.describe('App mode arrange step', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -3,8 +3,8 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
const RESIZE_NODE_ID = '1'

View File

@@ -5,7 +5,7 @@ import {
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
} from '@e2e/helpers/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/fixtures/utils/clipboardSpy'
} from '@e2e/helpers/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)

View File

@@ -8,7 +8,7 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
} from '@e2e/helpers/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']

View File

@@ -8,8 +8,8 @@ import {
builderSaveAs,
openWorkflowFromSidebar,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
} from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
/**
* After a first save, open save-as again from the chevron,

View File

@@ -1,175 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
nodeBounds: Size,
padding: number,
titleHeight: number
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
const DRAG_DELTA = { x: 550, y: 330 } as const
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
})
await note.centerOnNode()
return note
}
test('shift+drag rounds final node position to multiples of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 100 = (600, 300)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(600)
expect(after[1]).toBe(300)
})
test('grid size determines the snap multiple', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 50)
const note = await createNode(comfyPage)
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
// raw final world pos = (550, 330); rounded to nearest 50 = (550, 350)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(550)
expect(after[1]).toBe(350)
})
test('drag without shift bypasses snap regardless of grid size', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
const note = await createNode(comfyPage)
const before = await note.getProperty<[number, number]>('pos')
await note.dragBy(DRAG_DELTA)
const after = await note.getProperty<[number, number]>('pos')
expect(after[0]).toBe(before[0] + DRAG_DELTA.x)
expect(after[1]).toBe(before[1] + DRAG_DELTA.y)
})
})
test.describe('Comfy.GroupSelectedNodes.Padding', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
)
await comfyPage.command.executeCommand('Comfy.Canvas.SelectAll')
await comfyPage.command.executeCommand('Comfy.Graph.GroupSelectedNodes')
return comfyPage.page.evaluate(() => {
const group = window.app!.graph.groups[0]
return { width: group.size[0], height: group.size[1] }
})
}
test('padding=0 makes the group exactly enclose the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 0)
expect(group).toEqual(expectedGroupSize(nodeBounds, 0, titleHeight))
})
test('padding=50 grows the group by 100 around the selection', async ({
comfyPage
}) => {
const ksampler = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
)[0]
const nodeBounds = await ksampler.getBounding()
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
const group = await groupAroundAllNodesWithPadding(comfyPage, 50)
expect(group).toEqual(expectedGroupSize(nodeBounds, 50, titleHeight))
})
})
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const fileCombo = await loadImage.getWidget(0)
await fileCombo.click()
}
test('combo widget popup is scaled when setting is enabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('LiteGraph.ContextMenu.Scaling', true)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS(
'transform',
`matrix(${ZOOM_SCALE}, 0, 0, ${ZOOM_SCALE}, 0, 0)`
)
})
test('combo widget popup is not scaled when setting is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.ContextMenu.Scaling',
false
)
await openComboMenu(comfyPage)
const menu = litegraphContextMenu(comfyPage)
await expect(menu).toBeVisible()
await expect(menu).toHaveCSS('transform', 'none')
})
})
})

View File

@@ -1,400 +0,0 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow is expected to contain exactly two CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const p1 = await clipNodes[0].getPosition()
const p2 = await clipNodes[1].getPosition()
const margin = 64
const from = await comfyPage.canvasOps.toAbsolute({
x: Math.min(p1.x, p2.x) - margin,
y: Math.min(p1.y, p2.y) - margin
})
const to = await comfyPage.canvasOps.toAbsolute({
x: Math.max(p1.x, p2.x) + margin,
y: Math.max(p1.y, p2.y) + margin
})
return { from, to }
}
test.describe('Canvas settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Graph.CanvasInfo', () => {
test(
'toggles the bottom-left HUD',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const box = await comfyPage.canvas.boundingBox()
expect(box, 'Canvas bounding box must be available').not.toBeNull()
// HUD is drawn ~80px tall along the bottom edge of the canvas; grab a
// comfortable 180px × 160px strip to catch it across viewports.
const HUD_WIDTH = 180
const HUD_HEIGHT = 160
const hudClip = {
x: box!.x,
y: box!.y + box!.height - HUD_HEIGHT,
width: HUD_WIDTH,
height: HUD_HEIGHT
}
await test.step('Capture HUD region with setting off', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
await comfyPage.canvasOps.resetView()
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
await test.step('Capture HUD region with setting on', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}
)
})
test.describe('Comfy.Graph.CtrlShiftZoom', () => {
const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 }
const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 }
test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
await expect
.poll(() => comfyPage.canvasOps.getScale())
.not.toBeCloseTo(initialScale, 2)
})
test('Ctrl+Shift+drag does not zoom when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false)
await comfyPage.canvasOps.resetView()
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvasOps.ctrlShiftDrag(
CTRL_SHIFT_DRAG_FROM,
CTRL_SHIFT_DRAG_TO
)
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2)
})
})
test.describe('Comfy.Graph.LiveSelection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
})
test('selects nodes mid-drag when enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
})
test('defers selection to drag end when disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.page.mouse.move(from.x, from.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
await comfyPage.page.mouse.up()
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
const WHEEL_POS = { x: 400, y: 400 }
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'zoom'
)
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo(
initialScale,
3
)
})
test('wheel pans when set to panning', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
)
const initialScale = await comfyPage.canvasOps.getScale()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(1)
})
})
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
test('override to panning makes empty left-drag pan the canvas', async ({
comfyPage
}) => {
await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'panning'
)
})
await comfyPage.canvasOps.resetView()
const initialOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.canvasOps.dragAndDrop(
{ x: 200, y: 300 },
{ x: 400, y: 500 }
)
const offset = await comfyPage.canvasOps.getOffset()
expect(
Math.abs(offset[0] - initialOffset[0]) +
Math.abs(offset[1] - initialOffset[1])
).toBeGreaterThan(50)
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
})
test('override to select turns empty left-drag into a selection rectangle', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.LeftMouseClickBehavior',
'select'
)
const { from, to } = await getClipNodesDragBox(comfyPage)
await comfyPage.canvasOps.dragAndDrop(from, to)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(CLIP_NODE_COUNT)
})
})
test.describe('Pointer settings', () => {
/**
* Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
const holdDragAt = async (
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) => {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
await sleep(opts.holdMs)
await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
}
test('DoubleClickTime controls whether two clicks open the title editor', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.DoubleClickTitleToEdit',
true
)
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
'Default workflow must have CLIPTextEncode nodes'
).toHaveLength(CLIP_NODE_COUNT)
const titlePos = await clipNodes[0].getTitlePosition()
const CLICK_GAP_MS = 200
await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
100
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectHidden()
})
await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.DoubleClickTime',
1000
)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await sleep(CLICK_GAP_MS)
await comfyPage.canvasOps.mouseClickAt(titlePos)
await comfyPage.titleEditor.expectVisible()
})
})
test('ClickBufferTime governs the click-vs-drag time threshold', async ({
comfyPage
}) => {
// Keep drift generous so only elapsed time distinguishes click vs drag.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 2
const HOLD_MS = 250
await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => {
await comfyPage.settings.setSetting(
'Comfy.Pointer.ClickBufferTime',
2000
)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: HOLD_MS
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
test('ClickDrift governs the click-vs-drag distance threshold', async ({
comfyPage
}) => {
// Keep buffer generous so only drift distance matters.
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000)
const node = (
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
)[0]
const titlePos = await node.getTitlePosition()
const NUDGE = 8
await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(after.x).toBeCloseTo(before.x, 0)
expect(after.y).toBeCloseTo(before.y, 0)
})
await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => {
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1)
const before = await node.getPosition()
await holdDragAt(comfyPage, titlePos, {
dx: NUDGE,
dy: NUDGE,
holdMs: 0
})
const after = await node.getPosition()
expect(
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
).toBeGreaterThan(0)
})
})
})
test.describe('LiteGraph.Canvas.MaximumFps', () => {
// Behavioural FPS counting via rAF is not reliable under Playwright
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60)
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1)
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0)
await expect.poll(() => getFrameGap(comfyPage)).toBe(0)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/fixtures/utils/clipboardSpy'
} from '@e2e/helpers/clipboardSpy'
async function triggerConfigureError(
comfyPage: ComfyPage,

View File

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

View File

@@ -1,106 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Enable the show_signin_button server feature flag so LoginButton renders
* in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`).
* The flag is reset automatically on each fresh page load in beforeEach.
*/
async function enableLoginButtonFlag(page: Page): Promise<void> {
await page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: true
}
})
}
test.describe('Login Button', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
test.describe('Visibility', () => {
test('button is hidden when show_signin_button flag is off', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
show_signin_button: false
}
})
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeHidden()
})
test('button is visible when show_signin_button flag is enabled', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeVisible()
})
})
test.describe('ARIA', () => {
test('button has correct aria-label', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await expect(button).toHaveAttribute('aria-label', /.+/)
})
})
test.describe('Click behaviour', () => {
test('clicking the button opens the sign-in dialog', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const dialog = new SignInDialog(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click()
await expect(dialog.root).toBeVisible()
})
})
test.describe('Hover popover', () => {
test('hovering shows an informational popover', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
})
test('popover contains a Learn more link', async ({ comfyPage }) => {
await enableLoginButtonFlag(comfyPage.page)
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
const learnMoreLink = comfyPage.page.getByTestId(
TestIds.topbar.loginButtonPopoverLearnMore
)
await expect(learnMoreLink).toBeVisible()
await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/)
})
test('popover hides after mouse leaves the button area', async ({
comfyPage
}) => {
await enableLoginButtonFlag(comfyPage.page)
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
await button.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeVisible()
await comfyPage.canvas.hover()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
).toBeHidden()
})
})
})

View File

@@ -201,10 +201,12 @@ for (const mode of ['litegraph', 'vue'] as const) {
'subgraph blueprint added from search box enters ghost mode',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
await comfyPage.searchBoxV2.reload(comfyPage)
// Convert a node to a subgraph and publish it as a blueprint
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
@@ -229,8 +231,9 @@ for (const mode of ['litegraph', 'vue'] as const) {
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
// Open v2 search box and search for the published blueprint
await comfyPage.canvasOps.doubleClick()
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill(blueprintName)
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -267,65 +270,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(afterPlace!.ghost).toBe(false)
}
)
test(
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
true
)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
if (mode === 'vue') {
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
} else {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
}
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const subgraphId = await comfyPage.subgraph.getActiveGraphId()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.app!.canvas.state.ghostNodeId != null
)
)
.toBe(true)
await comfyPage.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
})
.toBe(true)
await expect
.poll(() => comfyPage.subgraph.getActiveGraphId())
.toBe(subgraphId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
)
.toBeNull()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount)
}
)
})
}

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'

View File

@@ -5,19 +5,32 @@ import {
test.describe('Node search box V2', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
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.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
@@ -27,12 +40,16 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
// Default results should be visible without typing.
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Default results should be visible without typing
await expect(searchBoxV2.results.first()).toBeVisible()
// Enter should add the first (selected) result
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
@@ -46,9 +63,12 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.reload(comfyPage)
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('favorites').click()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.filterBarButton('Bookmarked').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -59,10 +79,13 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
})
@@ -70,23 +93,26 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await test.step('Open Input filter popover', async () => {
await searchBoxV2.typeFilterButton('input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
})
// Click "Input" filter chip in the filter bar
await searchBoxV2.filterBarButton('Input').click()
await test.step('Select MODEL type', async () => {
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
})
// Filter options should appear
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
// Type to narrow and select MODEL
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Filter chip should appear and results should be filtered
await expect(
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
).toContainText('MODEL')
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
@@ -96,180 +122,32 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await test.step('First result is selected by default', async () => {
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// First result selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('ArrowDown moves selection to next result', async () => {
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
})
// ArrowDown moves selection
await comfyPage.page.keyboard.press('ArrowDown')
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
await test.step('ArrowUp moves selection back', async () => {
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
// ArrowUp moves back
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await test.step('Enter selects and adds the node', async () => {
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})
// Enter selects and adds node
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
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')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
})
})

View File

@@ -2,17 +2,27 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBoxV2.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.openByDoubleClickCanvas()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.dialog).toBeVisible()
})
@@ -22,40 +32,43 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
for (const closeKey of ['Enter', 'Escape'] as const) {
test(`Reopening search after ${closeKey} has no persisted state`, async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press(closeKey)
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
}
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
})
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -63,6 +76,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(samplingResults)
@@ -73,328 +87,58 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search first to keep the result set under the 64-item cap.
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await test.step('Apply Input/MODEL filter', async () => {
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
await test.step('Remove the filter chip', async () => {
await searchBoxV2.removeFilterChip()
await expect(searchBoxV2.filterChips).toHaveCount(0)
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
})
})
})
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
// Record initial result text for comparison
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
.poll(() => searchBoxV2.results.allTextContents())
.not.toEqual(unfilteredResults)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).toBeHidden()
await expect(searchBoxV2.results.first()).toBeVisible()
})
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const NODE_TYPE = 'CLIPTextEncode'
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const idsBefore = new Set(refsBefore.map((n) => n.id))
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('CLIP Text Encode')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
// A new CLIPTextEncode node should have been added.
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByType(NODE_TYPE)
.then((refs) => refs.length)
)
.toBe(refsBefore.length + 1)
// Verify the auto-connect: the newly-added node's CLIP input must be
// connected (proves the release wasn't just dropped).
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
const clipInput = await newNode!.getInput(0)
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Load')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.results.first()).toBeVisible()
const singleFilterCount = await searchBoxV2.results.count()
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('Sampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(unfilteredCount)
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.rootCategoryButton('comfy').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const comfyCount = await searchBoxV2.results.count()
// Under root filter, categories are prefixed (e.g. comfy/sampling).
await searchBoxV2.categoryButton('comfy/sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const samplingBtn = searchBoxV2.categoryButton('sampling')
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await test.step('Expanding sampling reveals its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeVisible()
})
await test.step('Collapsing sampling hides its subcategories', async () => {
await samplingBtn.click()
await expect(subcategory).toBeHidden()
})
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const parentCount = await searchBoxV2.results.count()
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
await expect
.poll(() => searchBoxV2.results.count())
.toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await expect(searchBoxV2.results.first()).toBeVisible()
const defaultCount = await searchBoxV2.results.count()
await searchBoxV2.categoryButton('sampling').click()
await expect
.poll(() => searchBoxV2.results.count())
.not.toBe(defaultCount)
await searchBoxV2.categoryButton('most-relevant').click()
await expect(searchBoxV2.results).toHaveCount(defaultCount)
})
test(
'Blueprint root chip filters to published blueprints',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await expect
.poll(() =>
comfyPage.nodeOps
.getNodeRefsByTitle('New Subgraph')
.then((refs) => refs.length)
)
.toBe(1)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
await subgraphNodes[0].click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const blueprintsChip = searchBoxV2.rootCategoryButton(
RootCategory.Blueprint
)
await expect(blueprintsChip).toBeVisible()
await blueprintsChip.click()
// Blueprints persist across tests on the same worker; filter by the
// unique name we just published rather than asserting the full list.
await expect(
searchBoxV2.results.filter({ hasText: blueprintName })
).toHaveCount(1)
}
)
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
await expect(searchBoxV2.results.first()).toBeVisible()
const count1 = await getCount()
await searchBoxV2.input.fill('Sa')
await expect.poll(getCount).toBeLessThan(count1)
const count2 = await getCount()
await searchBoxV2.input.fill('Sampler')
await expect.poll(getCount).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('input', 'MODEL')
await searchBoxV2.applyTypeFilter('output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const chipTexts = await searchBoxV2.filterChips.allTextContents()
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
})
test('Follow-cursor disabled places node without ghost mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
false
)
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).toBeHidden()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
await expect(
comfyPage.page.locator('[data-node-id][data-ghost]')
).toHaveCount(0)
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
})
})

View File

@@ -7,7 +7,7 @@ import {
drawStroke,
hasCanvasContent,
triggerSerialization
} from '@e2e/fixtures/utils/painter'
} from '@e2e/helpers/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {

View File

@@ -1,10 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
logMeasurement,
recordMeasurement
} from '@e2e/fixtures/utils/perfReporter'
import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
export class PropertiesPanelHelper {
@@ -9,14 +8,12 @@ export class PropertiesPanelHelper {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly closeButton: Locator
readonly titleEditor: TitleEditor
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.closeButton = this.root.locator('button[aria-pressed]')
this.titleEditor = new TitleEditor(this.root)
}
get tabs(): Locator {
@@ -31,6 +28,10 @@ export class PropertiesPanelHelper {
return this.panelTitle.locator('i[class*="lucide--pencil"]')
}
get titleInput(): Locator {
return this.root.getByTestId(TestIds.node.titleInput)
}
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
return this.root.locator('button', { hasText: state })
}
@@ -85,8 +86,8 @@ export class PropertiesPanelHelper {
async editTitle(newTitle: string): Promise<void> {
await this.titleEditIcon.click()
await this.titleEditor.expectVisible()
await this.titleEditor.setTitle(newTitle)
await this.titleInput.fill(newTitle)
await this.titleInput.press('Enter')
}
async searchWidgets(query: string): Promise<void> {

View File

@@ -5,7 +5,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/fixtures/utils/clipboardSpy'
} from '@e2e/helpers/clipboardSpy'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab

View File

@@ -18,7 +18,7 @@ test.describe('Properties panel - Title editing', () => {
test('should enter edit mode on pencil click', async () => {
await panel.titleEditIcon.click()
await panel.titleEditor.expectVisible()
await expect(panel.titleInput).toBeVisible()
})
test('should update node title on edit', async () => {

View File

@@ -1,121 +0,0 @@
import { mergeTests } from '@playwright/test'
import type { Locator, Page, Request } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const TOTAL_MOCK_JOBS = 20
const overflowJobsListRoutePattern = '**/api/jobs?*'
function isHistoryJobsRequest(url: string): boolean {
if (!url.includes('/api/jobs')) return false
const params = new URL(url).searchParams
const statuses = (params.get('status') ?? '').split(',')
return statuses.includes('completed')
}
async function captureNextHistoryRequest(
comfyPage: ComfyPage,
exec: ExecutionHelper
): Promise<Request> {
const requestPromise = comfyPage.page.waitForRequest(
(req) => isHistoryJobsRequest(req.url()),
{ timeout: 5000 }
)
exec.status(0)
return requestPromise
}
function getJobListResults(page: Page): Locator {
return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]')
}
test.describe('Queue settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Queue.MaxHistoryItems', () => {
test.describe('limit query parameter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(
createMockJobs(TOTAL_MOCK_JOBS)
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('limit query parameter on /api/jobs reflects the setting', async ({
comfyPage,
getWebSocket
}) => {
const TARGET_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
TARGET_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
const request = await captureNextHistoryRequest(comfyPage, exec)
const url = new URL(request.url())
expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT))
})
})
test('queue panel caps history items to the configured number', async ({
comfyPage,
getWebSocket
}) => {
// Add a mock route that returns all jobs regardless of the request's `limit` param
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await comfyPage.page.route(
overflowJobsListRoutePattern,
async (route) => {
const url = new URL(route.request().url())
if (!url.searchParams.get('status')?.includes('completed')) {
await route.continue()
return
}
const response = {
jobs: overflowJobs,
pagination: {
offset: 0,
limit: overflowJobs.length,
total: overflowJobs.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
)
const VISIBLE_LIMIT = 6
await comfyPage.settings.setSetting(
'Comfy.Queue.MaxHistoryItems',
VISIBLE_LIMIT
)
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
await captureNextHistoryRequest(comfyPage, exec)
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
const jobs = getJobListResults(comfyPage.page)
await expect(jobs.first()).toBeVisible()
await expect(jobs).toHaveCount(VISIBLE_LIMIT)
})
})
})

View File

@@ -2,6 +2,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
test('Properties panel opens with workflow overview', async ({
@@ -34,8 +35,11 @@ test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
// Click on the title to enter edit mode
await propertiesPanel.panelTitle.click()
await propertiesPanel.titleEditor.expectVisible()
await propertiesPanel.titleEditor.setTitle('My Custom Sampler')
const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput)
await expect(titleInput).toBeVisible()
await titleInput.fill('My Custom Sampler')
await titleInput.press('Enter')
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
})

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/utils/boundsUtils'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'

View File

@@ -42,11 +42,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
// Selection toolbox should be visible with multiple nodes selected
await expect(comfyPage.selectionToolbox).toBeVisible()
// Border is now drawn on canvas, check via screenshot
// Allow small anti-aliasing variance on the canvas-drawn selection border
// (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35)
await expect(comfyPage.canvas).toHaveScreenshot(
'selection-toolbox-multiple-nodes-border.png',
{ maxDiffPixels: 100 }
'selection-toolbox-multiple-nodes-border.png'
)
})

View File

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

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'

View File

@@ -3,11 +3,11 @@ 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 { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount
} from '@e2e/fixtures/utils/promotedWidgets'
} from '@e2e/helpers/promotedWidgets'
async function expectPromotedWidgetNamesToContain(
comfyPage: ComfyPage,

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`

View File

@@ -4,16 +4,18 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
import {
getPromotedWidgetCount,
getPromotedWidgetNames,
getPromotedWidgets
} from '@e2e/fixtures/utils/promotedWidgets'
} from '@e2e/helpers/promotedWidgets'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -40,6 +42,31 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
expect(results).toEqual(widgets.map(() => true))
}
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
@@ -498,4 +525,29 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -121,7 +121,10 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible()
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
})
test('should allow fitting group to contents', async ({ comfyPage }) => {

View File

@@ -7,7 +7,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
test.describe(
'Vue Node Bring to Front',

View File

@@ -66,8 +66,10 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
await comfyPage.titleEditor.expectVisible()
await comfyPage.titleEditor.setTitle('My Renamed Sampler')
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
await comfyPage.nextFrame()
const renamedNode =

View File

@@ -5,7 +5,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
} from '@e2e/helpers/promotedWidgets'
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
test('should display node title', async ({ comfyPage }) => {
@@ -21,8 +22,8 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
// Test cancel with Escape
await vueNode.title.dblclick()
await comfyPage.nextFrame()
await vueNode.titleEditor.input.fill('This Should Be Cancelled')
await vueNode.titleEditor.cancel()
await vueNode.titleInput.fill('This Should Be Cancelled')
await vueNode.titleInput.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
@@ -39,6 +40,9 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
await comfyPage.titleEditor.expectHidden()
const editingTitleInput = comfyPage.page.getByTestId(
TestIds.node.titleInput
)
await expect(editingTitleInput).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,10 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -24,11 +20,15 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
})
function getNode(comfyPage: ComfyPage) {
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(comfyPage: ComfyPage) {
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
@@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
// "Show advanced inputs" button should be present
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
@@ -58,41 +58,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
const vueNode =
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
await expect(showAdvancedButton).toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeHidden()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeVisible()
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
@@ -113,6 +92,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
await expect(node.getByText('Show advanced inputs')).toBeHidden()
})
})

View File

@@ -1,50 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = () =>
comfyPage.page.evaluate(
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
)
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1)
await legacyWidget.click({ position: { x: width - 20, y: height / 2 } })
await expect.poll(() => legacyWidgetRef.getValue()).toBe(0)
})
await test.step('Resize to update width', async () => {
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')
await expect(gutter).toBeVisible()
await comfyMouse.resizeByDragging(gutter, { x: -200 })
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
const intermediateWidth = await getWidth()
await comfyMouse.resizeByDragging(gutter, { x: 100 })
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
})
})

View File

@@ -1,185 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { WidgetBoundingBoxFixture } from '@e2e/fixtures/components/WidgetBoundingBox'
const NODE_ID = '1'
test.describe('Widget Bounding Box', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
})
test(
'Renders all four coordinate inputs with workflow values',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await expect(boundingBox.root).toBeVisible()
await expect(boundingBox.x.input).toHaveValue('0')
await expect(boundingBox.y.input).toHaveValue('0')
await expect(boundingBox.width.input).toHaveValue('512')
await expect(boundingBox.height.input).toHaveValue('512')
}
)
test('Typing into each coordinate updates only that coordinate', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('type X', async () => {
await boundingBox.x.type(25)
await expect(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('0')
await expect.soft(boundingBox.width.input).toHaveValue('512')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Y', async () => {
await boundingBox.y.type(40)
await expect(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.width.input).toHaveValue('512')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Width', async () => {
await boundingBox.width.type(200)
await expect(boundingBox.width.input).toHaveValue('200')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.height.input).toHaveValue('512')
})
await test.step('type Height', async () => {
await boundingBox.height.type(300)
await expect(boundingBox.height.input).toHaveValue('300')
await expect.soft(boundingBox.x.input).toHaveValue('25')
await expect.soft(boundingBox.y.input).toHaveValue('40')
await expect.soft(boundingBox.width.input).toHaveValue('200')
})
})
test('Negative X/Y values are clamped to min=0', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.x.type(50)
await expect(boundingBox.x.input).toHaveValue('50')
await boundingBox.x.type('-10')
await expect(boundingBox.x.input).toHaveValue('0')
await boundingBox.y.type(75)
await expect(boundingBox.y.input).toHaveValue('75')
await boundingBox.y.type('-50')
await expect(boundingBox.y.input).toHaveValue('0')
})
test('Width/Height values below 1 are clamped to min=1', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.width.type(0)
await expect(boundingBox.width.input).toHaveValue('1')
await boundingBox.height.type('-5')
await expect(boundingBox.height.input).toHaveValue('1')
})
test('Increment and decrement buttons change coordinate by step', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('increment X from 0 to 2', async () => {
await boundingBox.x.increment()
await boundingBox.x.increment()
await expect(boundingBox.x.input).toHaveValue('2')
})
await test.step('decrement X from 2 to 1', async () => {
await boundingBox.x.decrement()
await expect(boundingBox.x.input).toHaveValue('1')
})
await test.step('decrement Width from 512 to 510', async () => {
await boundingBox.width.decrement()
await boundingBox.width.decrement()
await expect(boundingBox.width.input).toHaveValue('510')
})
await test.step('increment Height from 512 to 513', async () => {
await boundingBox.height.increment()
await expect(boundingBox.height.input).toHaveValue('513')
})
})
test('Arrow keys step the focused input; PageUp/PageDown step by 10', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.width.focus()
await boundingBox.width.input.press('ArrowUp')
await expect(boundingBox.width.input).toHaveValue('513')
await boundingBox.width.input.press('ArrowDown')
await boundingBox.width.input.press('ArrowDown')
await expect(boundingBox.width.input).toHaveValue('511')
await boundingBox.width.input.press('PageUp')
await expect(boundingBox.width.input).toHaveValue('521')
await boundingBox.width.input.press('PageDown')
await expect(boundingBox.width.input).toHaveValue('511')
})
test('Decrement button is disabled when value equals min', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await test.step('X at 0 disables decrement', async () => {
await expect(boundingBox.x.input).toHaveValue('0')
await expect(boundingBox.x.decrementButton).toBeDisabled()
await expect(boundingBox.x.incrementButton).toBeEnabled()
})
await test.step('Width at 1 disables decrement', async () => {
await boundingBox.width.type(1)
await expect(boundingBox.width.input).toHaveValue('1')
await expect(boundingBox.width.decrementButton).toBeDisabled()
await expect(boundingBox.width.incrementButton).toBeEnabled()
})
await test.step('Incrementing X re-enables decrement', async () => {
await boundingBox.x.increment()
await expect(boundingBox.x.decrementButton).toBeEnabled()
})
})
test('Non-numeric input reverts to previous value on blur', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator(NODE_ID)
const boundingBox = new WidgetBoundingBoxFixture(node)
await boundingBox.x.type(42)
await expect(boundingBox.x.input).toHaveValue('42')
await boundingBox.x.input.fill('not a number')
await boundingBox.x.input.blur()
await expect(boundingBox.x.input).toHaveValue('42')
})
})

View File

@@ -257,8 +257,6 @@ it('should validate node definition', () => {
## Mocking Composables with Reactive State
> **Don't mock `vue-i18n`.** Mount with a real `createI18n` plugin instance instead — see [Don't Mock `vue-i18n` in `vitest-patterns.md`](./vitest-patterns.md#dont-mock-vue-i18n--use-a-real-plugin). This section applies to composables you own.
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules

View File

@@ -30,42 +30,9 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## Don't Mock `vue-i18n` — Use a Real Plugin
## i18n in Component Tests
Mount with a real `createI18n` instance instead of mocking `vue-i18n`. The plugin is cheap, owned by a third party (don't mock what you don't own), and a real instance exercises the same translation key resolution and pluralization logic that production uses.
This applies to **all tests** that touch a component or composable calling `useI18n()` — not just component tests.
```typescript
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} } // empty — assertions key off the translation key, not the rendered string
})
// Component tests: pass via global plugins
mount(MyComponent, { global: { plugins: [i18n] } })
// Composable tests: provide via a host component (see useMediaAssetActions.test.ts pattern)
const app = createApp(HostComponent)
app.use(i18n)
```
Real example: [`src/components/searchbox/v2/__test__/testUtils.ts`](../../src/components/searchbox/v2/__test__/testUtils.ts) exports a shared `testI18n` instance.
### Asserting on translation keys
With empty messages, `t('foo.bar')` returns `'foo.bar'` (the key). Assert against the key directly — no need to mock `t`:
```typescript
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'mediaAsset.selection.exportStarted' })
)
```
For pluralization / interpolation arguments, spy on the consumer (e.g. the toast `add` fn) and inspect the captured payload, rather than spying on `t` itself.
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns

View File

@@ -475,11 +475,6 @@ export default defineConfig([
{
group: ['./**', '../**'],
message: 'Use the @e2e/ path alias instead of relative imports.'
},
{
group: ['@e2e/helpers', '@e2e/helpers/*'],
message:
'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.'
}
]
}
@@ -498,11 +493,6 @@ export default defineConfig([
{
group: ['./**', '../**'],
message: 'Use the @e2e/ path alias instead of relative imports.'
},
{
group: ['@e2e/helpers', '@e2e/helpers/*'],
message:
'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.'
}
]
}

View File

@@ -55,9 +55,7 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**'
'.agents/checks/eslint.strict.config.js'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.15",
"version": "1.44.12",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -27,23 +27,6 @@
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
/* Plum */
--color-plum-300: #afa3db;
--color-plum-400: #8d7fc5;
--color-plum-500: #6b5ca8;
--color-plum-600: #49378b;
/* Ink */
--color-ink-100: #5c5362;
--color-ink-200: #4f4754;
--color-ink-300: #413b45;
--color-ink-400: #353139;
--color-ink-500: #312c34;
--color-ink-600: #29252c;
--color-ink-700: #232025;
--color-ink-800: #19161a;
--color-ink-900: #151317;
--color-white: #ffffff;
--color-black: #000000;

View File

@@ -41,6 +41,10 @@
--color-sand-300: #888682;
--color-sand-400: #eed7ac;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-azure-300: #78bae9;
--color-azure-400: #31b9f4;
--color-azure-600: #0b8ce9;
@@ -49,6 +53,7 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9c9eab;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -68,6 +73,10 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-error: #962a2a;
@@ -203,7 +212,7 @@
--node-component-slot-dot-outline-opacity: 5%;
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-ash-800);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-surface-highlight: var(--color-ash-500);
--node-component-surface-hovered: var(--color-smoke-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
@@ -222,7 +231,7 @@
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
--text-secondary: var(--color-smoke-800);
--text-secondary: var(--color-ash-500);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
@@ -259,7 +268,7 @@
--secondary-background-selected
);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-background-highlighted: var(--color-ash-500);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
@@ -339,19 +348,19 @@
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-smoke-800);
--node-component-header-icon: var(--color-slate-300);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-smoke-700);
--node-component-surface-highlight: var(--color-smoke-800);
--node-component-slot-text: var(--color-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-600);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-charcoal-200);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
@@ -369,7 +378,7 @@
);
--color-interface-panel-job-progress-border: var(--base-foreground);
--text-secondary: var(--color-smoke-700);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-white);
--input-surface: rgb(130 130 130 / 0.1);
@@ -409,7 +418,7 @@
--component-node-widget-background-disabled: var(
--color-alpha-charcoal-600-30
);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);

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