mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-13 19:20:37 +00:00
Compare commits
28 Commits
playwright
...
v1.43.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c289640e99 | ||
|
|
dc7c97c5ac | ||
|
|
8340d7655f | ||
|
|
1ffd92f910 | ||
|
|
81d3ef22b0 | ||
|
|
2d99fb446c | ||
|
|
dee236cd60 | ||
|
|
b12b20b5ab | ||
|
|
04f90b7a05 | ||
|
|
1e7c8d9889 | ||
|
|
367f810702 | ||
|
|
798f6de4a9 | ||
|
|
752641cc67 | ||
|
|
af0f7cb945 | ||
|
|
ac0175aa6a | ||
|
|
1e1b3884c5 | ||
|
|
bce7a168de | ||
|
|
e7c2cd04f4 | ||
|
|
391a6db056 | ||
|
|
4d4dca2a46 | ||
|
|
ba9f3481fb | ||
|
|
7cbd61aaea | ||
|
|
b09562a1bf | ||
|
|
cc8ef09d28 | ||
|
|
64917e5b6c | ||
|
|
0e7cab96b7 | ||
|
|
e0d16b7ee9 | ||
|
|
8eb1525171 |
74
.agents/checks/playwright-e2e.md
Normal file
74
.agents/checks/playwright-e2e.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: playwright-e2e
|
||||
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
|
||||
severity-default: medium
|
||||
tools: [Read, Grep]
|
||||
---
|
||||
|
||||
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
|
||||
|
||||
Reference docs (read if you need full context):
|
||||
|
||||
- `browser_tests/README.md` — setup, patterns, screenshot workflow
|
||||
- `browser_tests/AGENTS.md` — directory structure, fixture overview
|
||||
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
|
||||
|
||||
## Checks
|
||||
|
||||
### Flakiness Risks (Major)
|
||||
|
||||
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
|
||||
|
||||
3. **Keyboard actions without prior focus** — `page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
|
||||
|
||||
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
|
||||
|
||||
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
|
||||
|
||||
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
|
||||
|
||||
7. **Double-click without `{ delay }` option** — `dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
|
||||
|
||||
### Fixture & API Misuse (Medium)
|
||||
|
||||
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
|
||||
- `comfyPage.command.executeCommand()` for menu/command actions
|
||||
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
|
||||
- `comfyPage.canvasOps.resetView()` for view reset
|
||||
- `comfyPage.settings.setSetting()` for settings
|
||||
- Component page objects in `browser_tests/fixtures/components/`
|
||||
|
||||
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
|
||||
|
||||
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
|
||||
|
||||
### Convention Violations (Minor)
|
||||
|
||||
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
|
||||
|
||||
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
|
||||
|
||||
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
|
||||
|
||||
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
|
||||
|
||||
15. **Debug helpers left in committed code** — `debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
|
||||
|
||||
### Test Design (Nitpick)
|
||||
|
||||
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
|
||||
|
||||
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
|
||||
|
||||
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
|
||||
|
||||
## Rules
|
||||
|
||||
- Only review `.spec.ts` files and supporting code in `browser_tests/`
|
||||
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
|
||||
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
|
||||
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
|
||||
- Existing tests that predate conventions are acceptable to modify but not required to fix
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--clarificationsFile .github/license-clarifications.json \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
|
||||
18
.github/workflows/ci-tests-e2e.yaml
vendored
18
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -33,6 +33,20 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist-cloud
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Sharded chromium tests
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
@@ -97,14 +111,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
|
||||
path: dist/
|
||||
|
||||
- name: Start ComfyUI server
|
||||
|
||||
13
.github/workflows/ci-tests-unit.yaml
vendored
13
.github/workflows/ci-tests-unit.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
# Description: Unit and component testing with Vitest
|
||||
# Description: Unit and component testing with Vitest + coverage reporting
|
||||
name: 'CI: Tests Unit'
|
||||
|
||||
on:
|
||||
@@ -23,5 +23,12 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -216,6 +216,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
|
||||
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
|
||||
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
|
||||
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
|
||||
|
||||
## External Resources
|
||||
|
||||
|
||||
38
CODEOWNERS
38
CODEOWNERS
@@ -41,12 +41,46 @@
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
47
apps/website/src/components/AcademySection.vue
Normal file
47
apps/website/src/components/AcademySection.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.label"
|
||||
class="flex items-center gap-2 text-sm text-white"
|
||||
>
|
||||
<span aria-hidden="true">{{ feature.icon }}</span>
|
||||
<span>{{ feature.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
apps/website/src/components/CTASection.vue
Normal file
66
apps/website/src/components/CTASection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:href="card.href"
|
||||
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
|
||||
<h3 class="mt-4 text-xl font-semibold text-white">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
<span
|
||||
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
card.outlined
|
||||
? 'border border-brand-yellow text-brand-yellow'
|
||||
: 'bg-brand-yellow text-black'
|
||||
"
|
||||
>
|
||||
{{ card.cta }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<!-- TODO: Replace placeholder content with real quotes and case studies -->
|
||||
<script setup lang="ts">
|
||||
const studies = [
|
||||
{
|
||||
title: 'New Pipelines with Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'md:row-span-2'
|
||||
},
|
||||
{
|
||||
title: 'AI-Assisted Texture and Environment',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[300px] lg:col-span-2'
|
||||
},
|
||||
{
|
||||
title: 'Open-sourced the Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[200px]'
|
||||
},
|
||||
{
|
||||
title: 'Environment Generation',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: true,
|
||||
gridClass: 'min-h-[200px]'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
|
||||
<p class="mt-2 text-smoke-700">
|
||||
See how leading studios use Comfy in production
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento grid -->
|
||||
<div
|
||||
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="study in studies"
|
||||
:key="study.title"
|
||||
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
|
||||
:class="[
|
||||
study.gridClass,
|
||||
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="font-semibold"
|
||||
:class="study.highlight ? 'text-black' : 'text-white'"
|
||||
>
|
||||
{{ study.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm"
|
||||
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
|
||||
>
|
||||
{{ study.body }}
|
||||
</p>
|
||||
<a
|
||||
href="/case-studies"
|
||||
class="mt-4 text-sm underline"
|
||||
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
|
||||
>
|
||||
READ CASE STUDY
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
62
apps/website/src/components/GetStartedSection.vue
Normal file
62
apps/website/src/components/GetStartedSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div v-for="(step, index) in steps" :key="step.number" class="relative">
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-6xl font-bold text-brand-yellow/20">
|
||||
{{ step.number }}
|
||||
</span>
|
||||
<h3 class="mt-2 text-xl font-semibold text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
68
apps/website/src/components/HeroSection.vue
Normal file
68
apps/website/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
|
||||
>
|
||||
<!-- Left: C Monogram -->
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
v-for="btn in ctaButtons"
|
||||
:key="btn.label"
|
||||
:href="btn.href"
|
||||
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
btn.variant === 'solid'
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-brand-yellow text-brand-yellow'
|
||||
"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
26
apps/website/src/components/ManifestoSection.vue
Normal file
26
apps/website/src/components/ManifestoSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<!-- Decorative quote mark -->
|
||||
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
|
||||
«
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
<div
|
||||
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
51
apps/website/src/components/ProductShowcase.vue
Normal file
51
apps/website/src/components/ProductShowcase.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder video area -->
|
||||
<div
|
||||
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature labels -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
58
apps/website/src/components/SocialProofBar.vue
Normal file
58
apps/website/src/components/SocialProofBar.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
'Nike',
|
||||
'HP',
|
||||
'Autodesk',
|
||||
'Apple',
|
||||
'Ubisoft',
|
||||
'Lucid',
|
||||
'Amazon',
|
||||
'Netflix',
|
||||
'Pixomondo',
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-y border-white/10 bg-black py-16">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
<div
|
||||
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
|
||||
>
|
||||
<span
|
||||
v-for="company in logos"
|
||||
:key="company"
|
||||
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
|
||||
>
|
||||
{{ company }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics row -->
|
||||
<div
|
||||
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="text-center">
|
||||
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
|
||||
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
92
apps/website/src/components/TestimonialsSection.vue
Normal file
92
apps/website/src/components/TestimonialsSection.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => {
|
||||
if (activeFilter.value === 'All') return testimonials
|
||||
return testimonials.filter((t) => t.industry === activeFilter.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
activeFilter === industry
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonial cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<article
|
||||
v-for="testimonial in filteredTestimonials"
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
<p class="mt-4 text-sm font-semibold text-white">
|
||||
{{ testimonial.name }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ testimonial.title }}, {{ testimonial.company }}
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
74
apps/website/src/components/UseCaseSection.vue
Normal file
74
apps/website/src/components/UseCaseSection.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
class="mt-10 flex flex-col items-center gap-4"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="transition-colors"
|
||||
:class="
|
||||
index === activeCategory
|
||||
? 'text-2xl text-white'
|
||||
: 'text-xl text-ash-500 hover:text-white/70'
|
||||
"
|
||||
@click="activeCategory = index"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
67
apps/website/src/components/ValuePillars.vue
Normal file
67
apps/website/src/components/ValuePillars.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">
|
||||
{{ pillar.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ pillar.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
34
apps/website/src/pages/index.astro
Normal file
34
apps/website/src/pages/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../components/SiteNav.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import SocialProofBar from '../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../components/GetStartedSection.vue'
|
||||
import CTASection from '../components/CTASection.vue'
|
||||
import ManifestoSection from '../components/ManifestoSection.vue'
|
||||
import AcademySection from '../components/AcademySection.vue'
|
||||
import SiteFooter from '../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
34
apps/website/src/pages/zh-CN/index.astro
Normal file
34
apps/website/src/pages/zh-CN/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../../components/SiteNav.vue'
|
||||
import HeroSection from '../../components/HeroSection.vue'
|
||||
import SocialProofBar from '../../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../../components/GetStartedSection.vue'
|
||||
import CTASection from '../../components/CTASection.vue'
|
||||
import ManifestoSection from '../../components/ManifestoSection.vue'
|
||||
import AcademySection from '../../components/AcademySection.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -12,12 +12,13 @@ browser_tests/
|
||||
│ ├── ComfyMouse.ts - Mouse interaction helper
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── components/ - Page object components
|
||||
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
│ │ ├── KeyboardHelper.ts
|
||||
@@ -25,11 +26,18 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Utility functions
|
||||
│ └── 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. 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.
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 20,
|
||||
"cfg": 8.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"2": {
|
||||
"class_type": "NonExistentCustomNode_XYZ_12345",
|
||||
"inputs": {
|
||||
"input1": "test"
|
||||
},
|
||||
"_meta": { "title": "Missing Node" }
|
||||
},
|
||||
"3": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"_meta": { "title": "Empty Latent Image" }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import type {
|
||||
APIRequestContext,
|
||||
ExpectMatcherState,
|
||||
Locator,
|
||||
Page
|
||||
} from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { sleep } from './utils/timing'
|
||||
import { comfyExpect } from './utils/customMatchers'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
@@ -18,6 +15,7 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { QueuePanel } from './components/QueuePanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
@@ -39,7 +37,7 @@ import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import { assetPath } from './utils/paths'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
@@ -124,7 +122,7 @@ type KeysOfType<T, Match> = {
|
||||
}[keyof T]
|
||||
|
||||
class ConfirmDialog {
|
||||
private readonly root: Locator
|
||||
public readonly root: Locator
|
||||
public readonly delete: Locator
|
||||
public readonly overwrite: Locator
|
||||
public readonly reject: Locator
|
||||
@@ -199,6 +197,7 @@ export class ComfyPage {
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
@@ -242,10 +241,11 @@ export class ComfyPage {
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.dragDrop = new DragDropHelper(page)
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
@@ -343,8 +343,9 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/** @deprecated Use standalone `assetPath` from `browser_tests/fixtures/utils/assetPath` directly. */
|
||||
public assetPath(fileName: string) {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto() {
|
||||
@@ -358,7 +359,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return sleep(ms)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,49 +477,4 @@ export const comfyPageFixture = base.extend<{
|
||||
}
|
||||
})
|
||||
|
||||
const makeMatcher = function <T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
const value = await getValue(node)
|
||||
let assertion = expect(
|
||||
value,
|
||||
'Node is ' + (this.isNot ? '' : 'not ') + type
|
||||
)
|
||||
if (this.isNot) {
|
||||
assertion = assertion.not
|
||||
}
|
||||
await expect(async () => {
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
|
||||
await expect(async () => {
|
||||
expect(isFocused).toBe(!this.isNot)
|
||||
}).toPass(options)
|
||||
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
export { comfyExpect }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
@@ -33,6 +34,20 @@ export class ContextMenu {
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await expect
|
||||
.soft(this.page.getByRole('menuitem', { name: item }))
|
||||
.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
return this
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
|
||||
24
browser_tests/fixtures/components/QueuePanel.ts
Normal file
24
browser_tests/fixtures/components/QueuePanel.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
await this.moreOptionsButton.click()
|
||||
|
||||
const clearHistoryAction = this.page.getByTestId(
|
||||
TestIds.queue.clearHistoryAction
|
||||
)
|
||||
await expect(clearHistoryAction).toBeVisible()
|
||||
await clearHistoryAction.click()
|
||||
}
|
||||
}
|
||||
77
browser_tests/fixtures/components/SignInDialog.ts
Normal file
77
browser_tests/fixtures/components/SignInDialog.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
|
||||
export class SignInDialog extends BaseDialog {
|
||||
readonly emailInput: Locator
|
||||
readonly passwordInput: Locator
|
||||
readonly signInButton: Locator
|
||||
readonly forgotPasswordLink: Locator
|
||||
readonly apiKeyButton: Locator
|
||||
readonly termsLink: Locator
|
||||
readonly privacyLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
|
||||
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
|
||||
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
|
||||
this.forgotPasswordLink = this.root.getByText('Forgot password?')
|
||||
this.apiKeyButton = this.root.getByRole('button', {
|
||||
name: 'Comfy API Key'
|
||||
})
|
||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.page.evaluate(() => {
|
||||
void window.app!.extensionManager.dialog.showSignInDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.root.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get signUpLink() {
|
||||
return this.root.getByText('Sign up', { exact: true })
|
||||
}
|
||||
|
||||
get signInLink() {
|
||||
return this.root.getByText('Sign in', { exact: true })
|
||||
}
|
||||
|
||||
get signUpEmailInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-email')
|
||||
}
|
||||
|
||||
get signUpPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-password')
|
||||
}
|
||||
|
||||
get signUpConfirmPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
||||
}
|
||||
|
||||
get signUpButton() {
|
||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
||||
}
|
||||
|
||||
get apiKeyHeading() {
|
||||
return this.root.getByRole('heading', { name: 'API Key' })
|
||||
}
|
||||
|
||||
get apiKeyInput() {
|
||||
return this.root.locator('#comfy-org-api-key')
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.root.getByRole('button', { name: 'Back' })
|
||||
}
|
||||
|
||||
get dividerText() {
|
||||
return this.root.getByText('Or continue with')
|
||||
}
|
||||
}
|
||||
41
browser_tests/fixtures/data/README.md
Normal file
41
browser_tests/fixtures/data/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Mock Data Fixtures
|
||||
|
||||
Deterministic mock data for browser (Playwright) tests. Each fixture
|
||||
exports typed objects that conform to generated types from
|
||||
`packages/ingest-types` or Zod schemas in `src/schemas/`.
|
||||
|
||||
## Usage with `page.route()`
|
||||
|
||||
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
|
||||
> before the test body runs. Routes must be registered before navigation
|
||||
> to intercept initial page-load requests. Set up routes in a custom
|
||||
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
|
||||
|
||||
```ts
|
||||
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
|
||||
import { mockSystemStats } from '../fixtures/data/systemStats'
|
||||
|
||||
// Extend the base set with test-specific nodes
|
||||
const nodeDefs = createMockNodeDefinitions({
|
||||
MyCustomNode: {
|
||||
/* ... */
|
||||
}
|
||||
})
|
||||
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill({ json: nodeDefs })
|
||||
)
|
||||
|
||||
await page.route('**/api/system_stats', (route) =>
|
||||
route.fulfill({ json: mockSystemStats })
|
||||
)
|
||||
```
|
||||
|
||||
## Adding new fixtures
|
||||
|
||||
1. Locate the generated type in `packages/ingest-types` or Zod schema
|
||||
in `src/schemas/` for the endpoint you need.
|
||||
2. Create a new `.ts` file here that imports and satisfies the
|
||||
corresponding TypeScript type.
|
||||
3. Keep values realistic but stable — avoid dates, random IDs, or
|
||||
values that would cause test flakiness.
|
||||
155
browser_tests/fixtures/data/nodeDefinitions.ts
Normal file
155
browser_tests/fixtures/data/nodeDefinitions.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
22
browser_tests/fixtures/data/systemStats.ts
Normal file
22
browser_tests/fixtures/data/systemStats.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
export const mockSystemStats: SystemStatsResponse = {
|
||||
system: {
|
||||
os: 'posix',
|
||||
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
|
||||
embedded_python: false,
|
||||
comfyui_version: '0.3.10',
|
||||
pytorch_version: '2.4.0+cu124',
|
||||
argv: ['main.py', '--listen', '0.0.0.0'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
name: 'NVIDIA GeForce RTX 4090',
|
||||
type: 'cuda',
|
||||
vram_total: 25769803776,
|
||||
vram_free: 23622320128
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,12 +4,10 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
import { assetPath } from '../utils/paths'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly assetPath: (fileName: string) => string
|
||||
) {}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
private async nextFrame(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
@@ -49,7 +47,7 @@ export class DragDropHelper {
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface PerfMeasurement {
|
||||
eventListeners: number
|
||||
totalBlockingTimeMs: number
|
||||
frameDurationMs: number
|
||||
p95FrameDurationMs: number
|
||||
allFrameDurationsMs: number[]
|
||||
}
|
||||
|
||||
export class PerformanceHelper {
|
||||
@@ -101,13 +103,13 @@ export class PerformanceHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure average frame duration via rAF timing over a sample window.
|
||||
* Returns average ms per frame (lower = better, 16.67 = 60fps).
|
||||
* Measure individual frame durations via rAF timing over a sample window.
|
||||
* Returns all per-frame durations so callers can compute avg, p95, etc.
|
||||
*/
|
||||
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
|
||||
private async measureFrameDurations(sampleFrames = 30): Promise<number[]> {
|
||||
return this.page.evaluate((frames) => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(0), 5000)
|
||||
return new Promise<number[]>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 5000)
|
||||
const timestamps: number[] = []
|
||||
let count = 0
|
||||
function tick(ts: number) {
|
||||
@@ -118,11 +120,14 @@ export class PerformanceHelper {
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
if (timestamps.length < 2) {
|
||||
resolve(0)
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
const total = timestamps[timestamps.length - 1] - timestamps[0]
|
||||
resolve(total / (timestamps.length - 1))
|
||||
const durations: number[] = []
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
durations.push(timestamps[i] - timestamps[i - 1])
|
||||
}
|
||||
resolve(durations)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
@@ -177,11 +182,21 @@ export class PerformanceHelper {
|
||||
return after[key] - before[key]
|
||||
}
|
||||
|
||||
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
|
||||
const [totalBlockingTimeMs, allFrameDurationsMs] = await Promise.all([
|
||||
this.collectTBT(),
|
||||
this.measureFrameDuration()
|
||||
this.measureFrameDurations()
|
||||
])
|
||||
|
||||
const frameDurationMs =
|
||||
allFrameDurationsMs.length > 0
|
||||
? allFrameDurationsMs.reduce((a, b) => a + b, 0) /
|
||||
allFrameDurationsMs.length
|
||||
: 0
|
||||
|
||||
const sorted = [...allFrameDurationsMs].sort((a, b) => a - b)
|
||||
const p95FrameDurationMs =
|
||||
sorted.length > 0 ? sorted[Math.ceil(sorted.length * 0.95) - 1] : 0
|
||||
|
||||
return {
|
||||
name,
|
||||
durationMs: delta('Timestamp') * 1000,
|
||||
@@ -197,7 +212,9 @@ export class PerformanceHelper {
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
eventListeners: delta('JSEventListeners'),
|
||||
totalBlockingTimeMs,
|
||||
frameDurationMs
|
||||
frameDurationMs,
|
||||
p95FrameDurationMs,
|
||||
allFrameDurationsMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { assetPath } from '../utils/paths'
|
||||
|
||||
type FolderStructure = {
|
||||
[key: string]: FolderStructure | string
|
||||
@@ -20,7 +21,7 @@ export class WorkflowHelper {
|
||||
|
||||
for (const [key, value] of Object.entries(structure)) {
|
||||
if (typeof value === 'string') {
|
||||
const filePath = this.comfyPage.assetPath(value)
|
||||
const filePath = assetPath(value)
|
||||
result[key] = readFileSync(filePath, 'utf-8')
|
||||
} else {
|
||||
result[key] = this.convertLeafToContent(value)
|
||||
@@ -59,7 +60,7 @@ export class WorkflowHelper {
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
this.comfyPage.assetPath(`${workflowName}.json`)
|
||||
assetPath(`${workflowName}.json`)
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -146,6 +147,16 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
|
||||
?.isBusy,
|
||||
undefined,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
|
||||
async getExportedWorkflow(options?: {
|
||||
api?: false
|
||||
|
||||
@@ -20,6 +20,7 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
closeMinimapButton: 'close-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
@@ -51,7 +52,8 @@ export const TestIds = {
|
||||
topbar: {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button'
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -96,6 +98,10 @@ export const TestIds = {
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
@@ -124,4 +130,5 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
49
browser_tests/fixtures/utils/customMatchers.ts
Normal file
49
browser_tests/fixtures/utils/customMatchers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ExpectMatcherState, Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
|
||||
function makeMatcher<T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
type: string
|
||||
) {
|
||||
return async function (
|
||||
this: ExpectMatcherState,
|
||||
node: NodeReference,
|
||||
options?: { timeout?: number; intervals?: number[] }
|
||||
) {
|
||||
await expect(async () => {
|
||||
const value = await getValue(node)
|
||||
const assertion = this.isNot
|
||||
? expect(value, 'Node is ' + type).not
|
||||
: expect(value, 'Node is not ' + type)
|
||||
assertion.toBeTruthy()
|
||||
}).toPass({ timeout: 250, ...options })
|
||||
return {
|
||||
pass: !this.isNot,
|
||||
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyExpect = expect.extend({
|
||||
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||
await expect
|
||||
.poll(
|
||||
() => locator.evaluate((el) => el === document.activeElement),
|
||||
options
|
||||
)
|
||||
.toBe(!this.isNot)
|
||||
|
||||
const isFocused = await locator.evaluate(
|
||||
(el) => el === document.activeElement
|
||||
)
|
||||
return {
|
||||
pass: isFocused,
|
||||
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||
}
|
||||
}
|
||||
})
|
||||
3
browser_tests/fixtures/utils/paths.ts
Normal file
3
browser_tests/fixtures/utils/paths.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
3
browser_tests/fixtures/utils/timing.ts
Normal file
3
browser_tests/fixtures/utils/timing.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export function logMeasurement(
|
||||
export function recordMeasurement(m: PerfMeasurement) {
|
||||
mkdirSync(TEMP_DIR, { recursive: true })
|
||||
const filename = `${m.name}-${Date.now()}.json`
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
|
||||
const { allFrameDurationsMs: _, ...serializable } = m
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(serializable))
|
||||
}
|
||||
|
||||
export function writePerfReport(
|
||||
|
||||
29
browser_tests/tests/cloud.spec.ts
Normal file
29
browser_tests/tests/cloud.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
* These tests run against the cloud build (DISTRIBUTION=cloud) and verify
|
||||
* that cloud-specific behavior is present. In CI, no Firebase auth is
|
||||
* configured, so the auth guard redirects to /cloud/login. The tests
|
||||
* verify the cloud build loaded correctly by checking for cloud-only
|
||||
* routes and elements.
|
||||
*/
|
||||
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
126
browser_tests/tests/dialogs/queueClearHistory.spec.ts
Normal file
126
browser_tests/tests/dialogs/queueClearHistory.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.queuePanel.overlayToggle.click()
|
||||
})
|
||||
|
||||
test('Dialog opens from queue panel history actions menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog shows confirmation message with title, description, and assets note', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'All the finished or failed jobs below will be removed from this Job queue panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Close (X) button closes dialog without clearing history', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
let clearCalled = false
|
||||
await comfyPage.page.route('**/api/history', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
clearCalled = true
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
|
||||
await dialog.getByLabel('Close').click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(clearCalled).toBe(false)
|
||||
|
||||
await comfyPage.page.unroute('**/api/history')
|
||||
})
|
||||
|
||||
test('Confirm clears queue history and closes dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearPromise = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().includes('/api/history') && req.method() === 'POST'
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Clear' }).click()
|
||||
|
||||
const request = await clearPromise
|
||||
expect(request.postDataJSON()).toEqual({ clear: true })
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
await comfyPage.queuePanel.openClearHistoryDialog()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await expect(clearButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
94
browser_tests/tests/dialogs/signInDialog.spec.ts
Normal file
94
browser_tests/tests/dialogs/signInDialog.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { SignInDialog } from '../../fixtures/components/SignInDialog'
|
||||
|
||||
test.describe('Sign In dialog', { tag: '@ui' }, () => {
|
||||
let dialog: SignInDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
dialog = new SignInDialog(comfyPage.page)
|
||||
await dialog.open()
|
||||
})
|
||||
|
||||
test('Should open and show the sign-in form by default', async () => {
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
await expect(dialog.signInButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle from sign-in to sign-up form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.signUpEmailInput).toBeVisible()
|
||||
await expect(dialog.signUpPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
|
||||
await expect(dialog.signUpButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should toggle back from sign-up to sign-in form', async () => {
|
||||
await dialog.signUpLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Create an account' })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.signInLink.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.emailInput).toBeVisible()
|
||||
await expect(dialog.passwordInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should navigate to the API Key form and back', async () => {
|
||||
await dialog.apiKeyButton.click()
|
||||
|
||||
await expect(dialog.apiKeyHeading).toBeVisible()
|
||||
await expect(dialog.apiKeyInput).toBeVisible()
|
||||
|
||||
await dialog.backButton.click()
|
||||
await expect(
|
||||
dialog.root.getByRole('heading', { name: 'Log in to your account' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display Terms of Service and Privacy Policy links', async () => {
|
||||
await expect(dialog.termsLink).toBeVisible()
|
||||
await expect(dialog.termsLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/terms-of-service'
|
||||
)
|
||||
|
||||
await expect(dialog.privacyLink).toBeVisible()
|
||||
await expect(dialog.privacyLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.comfy.org/privacy'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display the "Or continue with" divider and API key button', async () => {
|
||||
await expect(dialog.dividerText).toBeVisible()
|
||||
await expect(dialog.apiKeyButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show forgot password link on sign-in form', async () => {
|
||||
await expect(dialog.forgotPasswordLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should close dialog via close button', async () => {
|
||||
await dialog.close()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should close dialog via Escape key', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
@@ -78,4 +78,66 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(toggleButton).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = 3
|
||||
canvas.ds.offset[0] = -800
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
34
browser_tests/tests/propertiesPanel/AGENTS.md
Normal file
34
browser_tests/tests/propertiesPanel/AGENTS.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Properties Panel E2E Tests
|
||||
|
||||
Tests for the right-side properties panel (`RightSidePanel.vue`).
|
||||
|
||||
## Structure
|
||||
|
||||
| File | Coverage |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
|
||||
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
|
||||
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
|
||||
| `titleEditing.spec.ts` | Node title editing via pencil icon |
|
||||
| `searchFiltering.spec.ts` | Widget search and clear |
|
||||
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
|
||||
| `infoTab.spec.ts` | Node help content |
|
||||
| `errorsTab.spec.ts` | Errors tab visibility |
|
||||
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
|
||||
|
||||
## Shared Helper
|
||||
|
||||
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
|
||||
|
||||
```typescript
|
||||
let panel: PropertiesPanelHelper
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
|
||||
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
|
||||
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.
|
||||
100
browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts
Normal file
100
browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
export class PropertiesPanelHelper {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
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]')
|
||||
}
|
||||
|
||||
get tabs(): Locator {
|
||||
return this.root.locator('nav button')
|
||||
}
|
||||
|
||||
getTab(label: string): Locator {
|
||||
return this.root.locator('nav button', { hasText: label })
|
||||
}
|
||||
|
||||
get titleEditIcon(): Locator {
|
||||
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 })
|
||||
}
|
||||
|
||||
getColorSwatch(colorName: string): Locator {
|
||||
return this.root.locator(`[data-testid="${colorName}"]`)
|
||||
}
|
||||
|
||||
get pinnedSwitch(): Locator {
|
||||
return this.root.locator('[data-p-checked]').first()
|
||||
}
|
||||
|
||||
get subgraphEditButton(): Locator {
|
||||
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
|
||||
}
|
||||
|
||||
get contentArea(): Locator {
|
||||
return this.root.locator('.scrollbar-thin')
|
||||
}
|
||||
|
||||
get errorsTabIcon(): Locator {
|
||||
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
|
||||
}
|
||||
|
||||
get viewAllSettingsButton(): Locator {
|
||||
return this.root.getByRole('button', { name: /view all settings/i })
|
||||
}
|
||||
|
||||
get collapseToggleButton(): Locator {
|
||||
return this.root.locator(
|
||||
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
|
||||
)
|
||||
}
|
||||
|
||||
async open(actionbar: Locator): Promise<void> {
|
||||
if (!(await this.root.isVisible())) {
|
||||
await actionbar.click()
|
||||
await expect(this.root).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (await this.root.isVisible()) {
|
||||
await this.closeButton.click()
|
||||
await expect(this.root).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async switchToTab(label: string): Promise<void> {
|
||||
await this.getTab(label).click()
|
||||
}
|
||||
|
||||
async editTitle(newTitle: string): Promise<void> {
|
||||
await this.titleEditIcon.click()
|
||||
await this.titleInput.fill(newTitle)
|
||||
await this.titleInput.press('Enter')
|
||||
}
|
||||
|
||||
async searchWidgets(query: string): Promise<void> {
|
||||
await this.searchBox.fill(query)
|
||||
}
|
||||
|
||||
async clearSearch(): Promise<void> {
|
||||
await this.searchBox.fill('')
|
||||
}
|
||||
}
|
||||
31
browser_tests/tests/propertiesPanel/errorsTab.spec.ts
Normal file
31
browser_tests/tests/propertiesPanel/errorsTab.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Errors tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(panel.errorsTabIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Errors tab when errors are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.errorsTabIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
22
browser_tests/tests/propertiesPanel/infoTab.spec.ts
Normal file
22
browser_tests/tests/propertiesPanel/infoTab.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Info tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Info')
|
||||
})
|
||||
|
||||
test('should show node help content', async () => {
|
||||
await expect(panel.contentArea).toBeVisible()
|
||||
await expect(
|
||||
panel.contentArea.getByRole('heading', { name: 'Inputs' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
126
browser_tests/tests/propertiesPanel/nodeSelection.spec.ts
Normal file
126
browser_tests/tests/propertiesPanel/nodeSelection.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node selection', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
})
|
||||
|
||||
test.describe('Single node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show node title in panel header', async () => {
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should show Parameters, Info, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Info')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Nodes tab for single node', async () => {
|
||||
await expect(panel.getTab('Nodes')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display node widgets in Parameters tab', async () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
test('should show item count in title', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.panelTitle).toContainText('3 items selected')
|
||||
})
|
||||
|
||||
test('should list all selected nodes in Parameters tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
panel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should not show Info tab for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection changes', () => {
|
||||
test('should update from no selection to node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should update from node selection back to no selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should update between different single node selections', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.panelTitle).toContainText('KSampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
|
||||
await expect(panel.panelTitle).toContainText('Empty Latent Image')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tab labels', () => {
|
||||
test('should show "Parameters" tab for single node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Nodes" tab label for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
122
browser_tests/tests/propertiesPanel/nodeSettings.spec.ts
Normal file
122
browser_tests/tests/propertiesPanel/nodeSettings.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Node settings', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await panel.switchToTab('Settings')
|
||||
})
|
||||
|
||||
test.describe('Node state', () => {
|
||||
test('should show Normal, Bypass, and Mute state buttons', async () => {
|
||||
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
|
||||
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Bypass mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should set node to Mute mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Mute').click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Muted')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should restore node to Normal mode', async ({ comfyPage }) => {
|
||||
await panel.getNodeStateButton('Bypass').click()
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
|
||||
|
||||
await panel.getNodeStateButton('Normal').click()
|
||||
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
|
||||
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node color', () => {
|
||||
test('should display color swatches', async () => {
|
||||
await expect(panel.getColorSwatch('noColor')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('red')).toBeVisible()
|
||||
await expect(panel.getColorSwatch('blue')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should apply color to node', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('should remove color with noColor swatch', async ({ comfyPage }) => {
|
||||
await panel.getColorSwatch('red').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color != null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await panel.getColorSwatch('noColor').click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const selected = window.app!.canvas.selected_nodes
|
||||
const node = Object.values(selected)[0]
|
||||
return node?.color
|
||||
})
|
||||
)
|
||||
.toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pinned state', () => {
|
||||
test('should display pinned toggle', async () => {
|
||||
await expect(panel.pinnedSwitch).toBeVisible()
|
||||
})
|
||||
|
||||
test('should toggle pinned state', async ({ comfyPage }) => {
|
||||
await panel.pinnedSwitch.click()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should unpin previously pinned node', async ({ comfyPage }) => {
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
|
||||
|
||||
await panel.pinnedSwitch.click()
|
||||
await expect(
|
||||
nodeLocator.getByTestId('node-pin-indicator')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
32
browser_tests/tests/propertiesPanel/openClose.spec.ts
Normal file
32
browser_tests/tests/propertiesPanel/openClose.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Open and close', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should open via actionbar toggle button', async ({ comfyPage }) => {
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via panel close button', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.closeButton.click()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should close via close button after opening', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
await panel.close()
|
||||
await expect(panel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Properties panel', () => {
|
||||
test('opens and updates title based on selection', async ({ comfyPage }) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
|
||||
await propertiesPanel.searchBox.fill('seed')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(0)
|
||||
|
||||
await propertiesPanel.searchBox.fill('')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
41
browser_tests/tests/propertiesPanel/searchFiltering.spec.ts
Normal file
41
browser_tests/tests/propertiesPanel/searchFiltering.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Search filtering', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
})
|
||||
|
||||
test('should filter nodes by search query', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('should restore all nodes when search is cleared', async () => {
|
||||
await panel.searchWidgets('seed')
|
||||
await panel.clearSearch()
|
||||
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
test('should show empty state for no matches', async () => {
|
||||
await panel.searchWidgets('nonexistent_widget_xyz')
|
||||
await expect(
|
||||
panel.contentArea.getByText(/no .* match|no results|no items/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
50
browser_tests/tests/propertiesPanel/titleEditing.spec.ts
Normal file
50
browser_tests/tests/propertiesPanel/titleEditing.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Title editing', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
})
|
||||
|
||||
test('should show pencil icon for editable title', async () => {
|
||||
await expect(panel.titleEditIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('should enter edit mode on pencil click', async () => {
|
||||
await panel.titleEditIcon.click()
|
||||
await expect(panel.titleInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('should update node title on edit', async () => {
|
||||
const newTitle = 'My Custom Sampler'
|
||||
await panel.editTitle(newTitle)
|
||||
await expect(panel.panelTitle).toContainText(newTitle)
|
||||
})
|
||||
|
||||
test('should not show pencil icon for multi-selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.selectNodes([
|
||||
'KSampler',
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show pencil icon when nothing is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
await expect(panel.titleEditIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
70
browser_tests/tests/propertiesPanel/workflowOverview.spec.ts
Normal file
70
browser_tests/tests/propertiesPanel/workflowOverview.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Properties panel - Workflow Overview', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.root).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "Workflow Overview" title when nothing is selected', async () => {
|
||||
await expect(panel.panelTitle).toContainText('Workflow Overview')
|
||||
})
|
||||
|
||||
test('should show Parameters, Nodes, and Settings tabs', async () => {
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await expect(panel.getTab('Nodes')).toBeVisible()
|
||||
await expect(panel.getTab('Settings')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show Info tab when nothing is selected', async () => {
|
||||
await expect(panel.getTab('Info')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Nodes tab and list all workflow nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await panel.switchToTab('Nodes')
|
||||
const nodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should filter nodes by search in Nodes tab', async () => {
|
||||
await panel.switchToTab('Nodes')
|
||||
await panel.searchWidgets('KSampler')
|
||||
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch to Settings tab and show global settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show "View all settings" button', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.viewAllSettingsButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Nodes section with toggles', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(
|
||||
panel.contentArea.getByRole('button', { name: 'NODES' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Canvas section with grid settings', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show Connection Links section', async () => {
|
||||
await panel.switchToTab('Settings')
|
||||
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,23 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
function createMockRelease(overrides?: Partial<ReleaseNote>): ReleaseNote {
|
||||
return {
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Release Notifications', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -22,15 +37,10 @@ test.describe('Release Notifications', () => {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
createMockRelease({
|
||||
content:
|
||||
'## New Features\n\n- Added awesome feature\n- Fixed important bug',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
'## New Features\n\n- Added awesome feature\n- Fixed important bug'
|
||||
})
|
||||
])
|
||||
})
|
||||
} else {
|
||||
@@ -157,16 +167,7 @@ test.describe('Release Notifications', () => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'high',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
body: JSON.stringify([createMockRelease({ attention: 'high' })])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
@@ -250,16 +251,7 @@ test.describe('Release Notifications', () => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
body: JSON.stringify([createMockRelease()])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
@@ -303,14 +295,10 @@ test.describe('Release Notifications', () => {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
createMockRelease({
|
||||
attention: 'low',
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue'
|
||||
})
|
||||
])
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,165 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const RENAMED_NAME = 'renamed_slot_name'
|
||||
const SECOND_RENAMED_NAME = 'second_renamed_name'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Shows current slot label (not stale) in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
if (initialInputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an input slot label for rightClickInputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the rename worked
|
||||
const afterFirstRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph))
|
||||
return { label: null, name: null, displayName: null }
|
||||
const slot = graph.inputs?.[0]
|
||||
return {
|
||||
label: slot?.label || null,
|
||||
name: slot?.name || null,
|
||||
displayName: slot?.displayName || slot?.label || slot?.name || null
|
||||
}
|
||||
})
|
||||
expect(afterFirstRename.label).toBe(RENAMED_NAME)
|
||||
|
||||
// Now rename again - this is where the bug would show
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
|
||||
test('Shows current output slot label in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
if (initialOutputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an output slot label for rightClickOutputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now rename again to check for stale content
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickOutputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
})
|
||||
})
|
||||
@@ -1,856 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
promptDialog: '.graphdialog input',
|
||||
nodeSearchContainer: '.node-search-container',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('I/O Slot Management', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add output slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('output')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can rename I/O slots', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename input slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename output slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const renamedOutputName = 'renamed_output'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
expect(newOutputName).toBe(renamedOutputName)
|
||||
expect(newOutputName).not.toBe(initialOutputLabel)
|
||||
})
|
||||
|
||||
test('Right-click context menu still works alongside double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Test that right-click still works for renaming
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const rightClickRenamedName = 'right_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(rightClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can double-click on slot label text to rename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
|
||||
const graph = app.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) {
|
||||
throw new Error('Expected to be in subgraph')
|
||||
}
|
||||
const input = graph.inputs?.[0]
|
||||
|
||||
if (!input?.labelPos) {
|
||||
throw new Error('Could not get label position for testing')
|
||||
}
|
||||
|
||||
// Use labelPos for more precise clicking on the text
|
||||
const testX = input.labelPos[0]
|
||||
const testY = input.labelPos[1]
|
||||
|
||||
// Create a minimal mock event with required properties
|
||||
// Full PointerEvent creation is unnecessary for this test
|
||||
const leftClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 0,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as Parameters<typeof graph.inputNode.onPointerDown>[0]
|
||||
|
||||
const inputNode = graph.inputNode
|
||||
if (inputNode?.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
leftClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click if pointer has the handler
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(leftClickEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const labelClickRenamedName = 'label_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
test('Can create widget from link with compressed target_slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
const step = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes[0].widgets![0].options.step
|
||||
})
|
||||
expect(step).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations Inside Subgraphs', () => {
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.canvas.graph!.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// Undo
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.keyboard.redo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Navigation and UI', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const textareaCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(textareaCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that the subgraph node has no widgets after removing the text slot
|
||||
const widgetCount = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
|
||||
})
|
||||
|
||||
expect(widgetCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(subgraphCount).toBe(parentCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Change the Exit Subgraph keybinding from Escape to Alt+Q
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'q',
|
||||
ctrl: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'Escape',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Reload the page
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Press Escape - should close dialog, not exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
154
browser_tests/tests/subgraph/subgraphCrud.spec.ts
Normal file
154
browser_tests/tests/subgraph/subgraphCrud.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
|
||||
test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,132 +1,19 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
} from '../../helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Lifecycle Edge Behaviors',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
438
browser_tests/tests/subgraph/subgraphNavigation.spec.ts
Normal file
438
browser_tests/tests/subgraph/subgraphNavigation.spec.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
nodeSearchContainer: '.node-search-container'
|
||||
} as const
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Breadcrumb and Workflow Context', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Navigation Hotkeys', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Change the Exit Subgraph keybinding from Escape to Alt+Q
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'q',
|
||||
ctrl: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: {
|
||||
key: 'Escape',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Reload the page
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Press Escape - should close dialog, not exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Viewport', () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (
|
||||
n as unknown as { isSubgraphNode: () => boolean }
|
||||
).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Progress State', () => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
461
browser_tests/tests/subgraph/subgraphNested.spec.ts
Normal file
461
browser_tests/tests/subgraph/subgraphNested.spec.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Nested subgraph configure order', () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page,
|
||||
['No link found', 'Failed to resolve legacy -1']
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode()
|
||||
? hostNode.subgraph
|
||||
: null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved:
|
||||
interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (
|
||||
(innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[]
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
comfyExpect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await comfyExpect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for PR #10532:
|
||||
* Packing all nodes inside a subgraph into a nested subgraph was causing
|
||||
* the parent subgraph node's promoted widget values to go blank.
|
||||
*
|
||||
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
|
||||
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
|
||||
* at promotion time). After repointing, input._widget still pointed to
|
||||
* removed node IDs, causing missing-node failures and blank values on the
|
||||
* next checkState cycle.
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph pack preserves promoted widget values',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
|
||||
const HOST_NODE_ID = '57'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
|
||||
// 1. Verify initial promoted widget values via Vue node DOM
|
||||
const widthWidget = nodeLocator
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightWidget = nodeLocator
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsWidget = nodeLocator
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthWidget)
|
||||
const heightControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightWidget)
|
||||
const stepsControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
|
||||
await comfyExpect(async () => {
|
||||
await comfyExpect(widthControls.input).toHaveValue('1024')
|
||||
await comfyExpect(heightControls.input).toHaveValue('1024')
|
||||
await comfyExpect(stepsControls.input).toHaveValue('8')
|
||||
await comfyExpect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeAfter).toBeVisible()
|
||||
|
||||
const widthAfter = nodeAfter
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightAfter = nodeAfter
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsAfter = nodeAfter
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthAfter)
|
||||
const heightControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightAfter)
|
||||
const stepsControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
|
||||
|
||||
await comfyExpect(async () => {
|
||||
await comfyExpect(widthControlsAfter.input).toHaveValue('1024')
|
||||
await comfyExpect(heightControlsAfter.input).toHaveValue('1024')
|
||||
await comfyExpect(stepsControlsAfter.input).toHaveValue('8')
|
||||
await comfyExpect(textAfter).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Verify the host node is visible
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
|
||||
// Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await comfyExpect(async () => {
|
||||
const result = await comfyPage.page.evaluate((hostId) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
) {
|
||||
return { error: 'Host node not found or not a subgraph node' }
|
||||
}
|
||||
|
||||
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
|
||||
const entries = (proxyWidgets as unknown[])
|
||||
.filter(
|
||||
(e): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string' &&
|
||||
!e[1].startsWith('$$')
|
||||
)
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(
|
||||
Number(nodeId)
|
||||
)
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return { entries, count: entries.length }
|
||||
}, HOST_NODE_ID)
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
const { entries, count } = result as {
|
||||
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
|
||||
count: number
|
||||
}
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
entry.resolved,
|
||||
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await comfyExpect(widgets).toHaveCount(1)
|
||||
await comfyExpect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidget).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
77
browser_tests/tests/subgraph/subgraphOperations.spec.ts
Normal file
77
browser_tests/tests/subgraph/subgraphOperations.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph Internal Operations',
|
||||
{ tag: ['@slow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.canvas.graph!.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// Undo
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.keyboard.redo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,12 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../../helpers/fitToView'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
getPromotedWidgetCount
|
||||
} from '../../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
@@ -480,106 +479,6 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vue Mode - Promoted Preview Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
216
browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
Normal file
216
browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '../../helpers/promotedWidgets'
|
||||
|
||||
// Constants
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe(
|
||||
'Subgraph Promoted Widget DOM',
|
||||
{ tag: ['@slow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Navigation and Persistence', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(subgraphCount).toBe(parentCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Cleanup', () => {
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const textareaCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(textareaCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that the subgraph node has no widgets after removing the text slot
|
||||
const widgetCount = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
|
||||
})
|
||||
|
||||
expect(widgetCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Positioning', () => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator
|
||||
.getByLabel('seed', { exact: true })
|
||||
.first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
async function createSubgraphAndNavigateInto(comfyPage: ComfyPage) {
|
||||
const subgraphNode =
|
||||
433
browser_tests/tests/subgraph/subgraphSerialization.spec.ts
Normal file
433
browser_tests/tests/subgraph/subgraphSerialization.spec.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount
|
||||
} from '../../helpers/promotedWidgets'
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('Promoted widget renders with normalized name, not legacy prefix', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The promoted widget should render with the clean name "string_a",
|
||||
// not the legacy-prefixed "6: 3: string_a".
|
||||
const promotedWidget = outerNode
|
||||
.getByLabel('string_a', { exact: true })
|
||||
.first()
|
||||
await expect(promotedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// Both widget rows should be valid "string_a" widgets — no stale
|
||||
// "Disconnected" placeholders from unresolved legacy entries.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widget value is editable as a text input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
762
browser_tests/tests/subgraph/subgraphSlots.spec.ts
Normal file
762
browser_tests/tests/subgraph/subgraphSlots.spec.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const RENAMED_NAME = 'renamed_slot_name'
|
||||
const SECOND_RENAMED_NAME = 'second_renamed_name'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('I/O Slot CRUD', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add output slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('output')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename', () => {
|
||||
test('Can rename I/O slots via right-click context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename input slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename output slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const renamedOutputName = 'renamed_output'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
expect(newOutputName).toBe(renamedOutputName)
|
||||
expect(newOutputName).not.toBe(initialOutputLabel)
|
||||
})
|
||||
|
||||
test('Right-click context menu still works alongside double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Test that right-click still works for renaming
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const rightClickRenamedName = 'right_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(rightClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can double-click on slot label text to rename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
|
||||
const graph = app.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) {
|
||||
throw new Error('Expected to be in subgraph')
|
||||
}
|
||||
const input = graph.inputs?.[0]
|
||||
|
||||
if (!input?.labelPos) {
|
||||
throw new Error('Could not get label position for testing')
|
||||
}
|
||||
|
||||
// Use labelPos for more precise clicking on the text
|
||||
const testX = input.labelPos[0]
|
||||
const testY = input.labelPos[1]
|
||||
|
||||
// Create a minimal mock event with required properties
|
||||
// Full PointerEvent creation is unnecessary for this test
|
||||
const leftClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 0,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as Parameters<typeof graph.inputNode.onPointerDown>[0]
|
||||
|
||||
const inputNode = graph.inputNode
|
||||
if (inputNode?.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
leftClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click if pointer has the handler
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(leftClickEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const labelClickRenamedName = 'label_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename Dialog', () => {
|
||||
test('Shows current slot label (not stale) in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
if (initialInputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an input slot label for rightClickInputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the rename worked
|
||||
const afterFirstRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph))
|
||||
return { label: null, name: null, displayName: null }
|
||||
const slot = graph.inputs?.[0]
|
||||
return {
|
||||
label: slot?.label || null,
|
||||
name: slot?.name || null,
|
||||
displayName: slot?.displayName || slot?.label || slot?.name || null
|
||||
}
|
||||
})
|
||||
expect(afterFirstRename.label).toBe(RENAMED_NAME)
|
||||
|
||||
// Now rename again - this is where the bug would show
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(
|
||||
SELECTORS.promptDialog
|
||||
)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
|
||||
test('Shows current output slot label in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
if (initialOutputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an output slot label for rightClickOutputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now rename again to check for stale content
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickOutputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(
|
||||
SELECTORS.promptDialog
|
||||
)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename Propagation', () => {
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await comfyExpect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = SELECTORS.promptDialog
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await comfyExpect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Compressed target_slot', () => {
|
||||
test('Can create widget from link with compressed target_slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
const step = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes[0].widgets![0].options.step
|
||||
})
|
||||
expect(step).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Alignment', () => {
|
||||
/**
|
||||
* Regression test for link misalignment on SubgraphNodes when loading
|
||||
* workflows with workflowRendererVersion: "LG".
|
||||
*
|
||||
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
|
||||
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
|
||||
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
|
||||
* slot offsets. The fix uses DOM-relative measurement instead.
|
||||
*/
|
||||
test('slot positions stay within node bounds after loading LG workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const SLOT_BOUNDS_MARGIN = 20
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const workflowPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../../assets/subgraphs/basic-subgraph.json'
|
||||
)
|
||||
const workflow = JSON.parse(
|
||||
readFileSync(workflowPath, 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
workflow.extra = {
|
||||
...workflow.extra,
|
||||
workflowRendererVersion: 'LG'
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(wf) =>
|
||||
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
}),
|
||||
workflow
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Slot Position', () => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = SELECTORS.promptDialog
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph input slot rename propagation',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy prefixed proxyWidget normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('Promoted widget renders with normalized name, not legacy prefix', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The promoted widget should render with the clean name "string_a",
|
||||
// not the legacy-prefixed "6: 3: string_a".
|
||||
const promotedWidget = outerNode
|
||||
.getByLabel('string_a', { exact: true })
|
||||
.first()
|
||||
await expect(promotedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// Both widget rows should be valid "string_a" widgets — no stale
|
||||
// "Disconnected" placeholders from unresolved legacy entries.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(row.getByLabel('string_a', { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widget value is editable as a text input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page, [
|
||||
'No link found',
|
||||
'Failed to resolve legacy -1'
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
expect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
|
||||
expect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
expect(values).toContain('11111111111')
|
||||
expect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await expect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Regression test for PR #10532:
|
||||
* Packing all nodes inside a subgraph into a nested subgraph was causing
|
||||
* the parent subgraph node's promoted widget values to go blank.
|
||||
*
|
||||
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
|
||||
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
|
||||
* at promotion time). After repointing, input._widget still pointed to
|
||||
* removed node IDs, causing missing-node failures and blank values on the
|
||||
* next checkState cycle.
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph pack preserves promoted widget values',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
|
||||
const HOST_NODE_ID = '57'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// 1. Verify initial promoted widget values via Vue node DOM
|
||||
const widthWidget = nodeLocator
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
const heightWidget = nodeLocator
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsWidget = nodeLocator
|
||||
.getByLabel('steps', { exact: true })
|
||||
.first()
|
||||
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthWidget)
|
||||
const heightControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightWidget)
|
||||
const stepsControls =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(widthControls.input).toHaveValue('1024')
|
||||
await expect(heightControls.input).toHaveValue('1024')
|
||||
await expect(stepsControls.input).toHaveValue('8')
|
||||
await expect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeAfter).toBeVisible()
|
||||
|
||||
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
|
||||
const heightAfter = nodeAfter
|
||||
.getByLabel('height', { exact: true })
|
||||
.first()
|
||||
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
|
||||
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
|
||||
|
||||
const widthControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(widthAfter)
|
||||
const heightControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(heightAfter)
|
||||
const stepsControlsAfter =
|
||||
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(widthControlsAfter.input).toHaveValue('1024')
|
||||
await expect(heightControlsAfter.input).toHaveValue('1024')
|
||||
await expect(stepsControlsAfter.input).toHaveValue('8')
|
||||
await expect(textAfter).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Verify the host node is visible
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await expect(async () => {
|
||||
const result = await comfyPage.page.evaluate((hostId) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
) {
|
||||
return { error: 'Host node not found or not a subgraph node' }
|
||||
}
|
||||
|
||||
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
|
||||
const entries = (proxyWidgets as unknown[])
|
||||
.filter(
|
||||
(e): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string' &&
|
||||
!e[1].startsWith('$$')
|
||||
)
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return { entries, count: entries.length }
|
||||
}, HOST_NODE_ID)
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
const { entries, count } = result as {
|
||||
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
|
||||
count: number
|
||||
}
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
entry.resolved,
|
||||
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await expect(widgets).toHaveCount(1)
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph progress clear on navigation',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,132 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for link misalignment on SubgraphNodes when loading
|
||||
* workflows with workflowRendererVersion: "LG".
|
||||
*
|
||||
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
|
||||
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
|
||||
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
|
||||
* slot offsets. The fix uses DOM-relative measurement instead.
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph slot alignment after LG layout scale',
|
||||
{ tag: ['@subgraph', '@canvas'] },
|
||||
() => {
|
||||
test('slot positions stay within node bounds after loading LG workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const SLOT_BOUNDS_MARGIN = 20
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const workflowPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../assets/subgraphs/basic-subgraph.json'
|
||||
)
|
||||
const workflow = JSON.parse(
|
||||
readFileSync(workflowPath, 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
workflow.extra = {
|
||||
...workflow.extra,
|
||||
workflowRendererVersion: 'LG'
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(wf) =>
|
||||
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
}),
|
||||
workflow
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,114 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkflowTemplates } from '../../src/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
@@ -244,7 +245,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await comfyPage.page.route(
|
||||
'**/templates/index.json',
|
||||
async (route, _) => {
|
||||
const response = [
|
||||
const response: WorkflowTemplates[] = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
|
||||
364
browser_tests/tests/workflowPersistence.spec.ts
Normal file
364
browser_tests/tests/workflowPersistence.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow Persistence', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Rapid tab switching does not desync workflow and graph state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — desynced workflow/graph state during loading'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-A')
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.menu.topbar.saveWorkflow('rapid-B')
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await tab.switchToWorkflow('rapid-B')
|
||||
}
|
||||
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
|
||||
await tab.switchToWorkflow('rapid-A')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountA)
|
||||
})
|
||||
|
||||
test('Node outputs are preserved when switching workflow tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #9380 — ChangeTracker.store() did not save nodeOutputs, losing preview images on tab switch'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
|
||||
|
||||
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
expect(firstNode).toBeTruthy()
|
||||
const nodeId = firstNode!.id
|
||||
|
||||
// Simulate node outputs as if execution completed
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
const outputStore = window.app!.nodeOutputs
|
||||
if (outputStore) {
|
||||
outputStore[id] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}, String(nodeId))
|
||||
|
||||
// Trigger changeTracker to capture current state including outputs
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsBefore = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsBefore).toBeTruthy()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('outputs-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputsAfter = await comfyPage.page.evaluate((id) => {
|
||||
return window.app!.nodeOutputs?.[id]
|
||||
}, String(nodeId))
|
||||
expect(outputsAfter).toBeTruthy()
|
||||
expect(outputsAfter?.images).toBeDefined()
|
||||
})
|
||||
|
||||
test('Loading a new workflow cleanly replaces the previous graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commit 44bb6f13 — canvas graph not reset before workflow load'
|
||||
})
|
||||
|
||||
const defaultNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(defaultNodeCount).toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(1)
|
||||
|
||||
const nodes = await comfyPage.nodeOps.getNodes()
|
||||
expect(nodes[0].type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('Widget values on nodes are preserved across workflow tab switches', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #7648 — component widget state lost on graph change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('widget-state-test')
|
||||
|
||||
// Read widget values via page.evaluate — these are internal LiteGraph
|
||||
// state not exposed through DOM
|
||||
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('widget-state-test')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph.nodes
|
||||
const results: Record<string, unknown[]> = {}
|
||||
for (const node of nodes) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
results[node.id] = node.widgets.map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
|
||||
})
|
||||
|
||||
test('API format workflow with missing node types partially loads', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9694 — loadApiJson early-returned on missing node types'
|
||||
})
|
||||
|
||||
const fixturePath = comfyPage.assetPath(
|
||||
'nodes/api_workflow_with_missing_nodes.json'
|
||||
)
|
||||
const apiWorkflow = JSON.parse(readFileSync(fixturePath, 'utf-8'))
|
||||
|
||||
await comfyPage.page.evaluate(async (workflow) => {
|
||||
await window.app!.loadApiJson(workflow, 'test-api-workflow.json')
|
||||
}, apiWorkflow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Known nodes (KSampler, EmptyLatentImage) should load; unknown node skipped
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
|
||||
const nodeTypes = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.nodes.map((n: { type: string }) => n.type)
|
||||
})
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
expect(nodeTypes).toContain('EmptyLatentImage')
|
||||
expect(nodeTypes).not.toContain('NonExistentCustomNode_XYZ_12345')
|
||||
})
|
||||
|
||||
test('Canvas has auxclick handler to prevent middle-click paste', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8259 — middle-click paste duplicates entire workflow on Linux'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
button: 'middle',
|
||||
position: { x: 100, y: 100 }
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountAfter).toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain transient blob: URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #8715 — transient image URLs leaked into workflow serialization'
|
||||
})
|
||||
|
||||
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
for (const node of exportedWorkflow.nodes) {
|
||||
if (node.widgets_values && Array.isArray(node.widgets_values)) {
|
||||
for (const value of node.widgets_values) {
|
||||
if (typeof value === 'string') {
|
||||
expect(value).not.toMatch(/^blob:/)
|
||||
expect(value).not.toMatch(/^https?:\/\/.*\/api\/view/)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Changing locale does not break workflow operations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #8963 — template workflows not reloaded on locale change'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.menu.topbar.saveWorkflow('locale-test')
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await expect.poll(() => tab.getActiveWorkflowName()).toBe('locale-test')
|
||||
})
|
||||
|
||||
test('Node links survive save/load/switch cycles', async ({ comfyPage }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description: 'PR #9533 — node links must survive serialization roundtrips'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
// Link count requires internal graph state — not exposed via DOM
|
||||
const linkCountBefore = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountBefore).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('links-test')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await tab.switchToWorkflow('links-test')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const linkCountAfter = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.links
|
||||
? Object.keys(window.app!.graph.links).length
|
||||
: 0
|
||||
})
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Commits 91f197d9d + a1b7e57bc — splitter panel size drift on reload'
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Splitter.MainSplitter',
|
||||
JSON.stringify([30, 70])
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const storedSizes = await comfyPage.page.evaluate(() => {
|
||||
const raw = localStorage.getItem('Comfy.Splitter.MainSplitter')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
|
||||
expect(storedSizes).toBeTruthy()
|
||||
expect(Array.isArray(storedSizes)).toBe(true)
|
||||
for (const size of storedSizes as number[]) {
|
||||
expect(typeof size).toBe('number')
|
||||
expect(size).toBeGreaterThanOrEqual(0)
|
||||
expect(size).not.toBeNaN()
|
||||
}
|
||||
const total = (storedSizes as number[]).reduce(
|
||||
(a: number, b: number) => a + b,
|
||||
0
|
||||
)
|
||||
expect(total).toBeGreaterThan(90)
|
||||
expect(total).toBeLessThanOrEqual(101)
|
||||
})
|
||||
})
|
||||
@@ -10,107 +10,191 @@ See `docs/testing/*.md` for detailed patterns.
|
||||
## Best Practices
|
||||
|
||||
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
|
||||
- Do NOT use `waitForTimeout` — use Locator actions and retrying assertions
|
||||
- Prefer specific selectors (role, label, test-id)
|
||||
- Test across viewports
|
||||
|
||||
## Window Globals
|
||||
|
||||
Browser tests access `window.app`, `window.graph`, and `window.LiteGraph` which are
|
||||
optional in the main app types. In E2E tests, use non-null assertions (`!`):
|
||||
optional in the main app types. Use non-null assertions (`!`) in E2E tests only:
|
||||
|
||||
```typescript
|
||||
window.app!.graph!.nodes
|
||||
window.LiteGraph!.registered_node_types
|
||||
```
|
||||
|
||||
This is the **only context** where non-null assertions are acceptable.
|
||||
TODO: Consolidate into a central utility (e.g., `getApp()`) with runtime type checking.
|
||||
|
||||
**TODO:** Consolidate these references into a central utility (e.g., `getApp()`) that
|
||||
performs proper runtime type checking, removing the need for scattered `!` assertions.
|
||||
## Type Assertions
|
||||
|
||||
## Type Assertions in E2E Tests
|
||||
Use specific type assertions when needed, never `as any`.
|
||||
|
||||
E2E tests may use **specific** type assertions when needed, but **never** `as any`.
|
||||
|
||||
### Acceptable Patterns
|
||||
Acceptable:
|
||||
|
||||
```typescript
|
||||
// ✅ Non-null assertions for window globals
|
||||
window.app!.extensionManager
|
||||
|
||||
// ✅ Specific type assertions with documentation
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestSetting' as TestSettingId
|
||||
|
||||
// ✅ Test-local type helpers
|
||||
type TestSettingId = keyof Settings
|
||||
```
|
||||
|
||||
### Forbidden Patterns
|
||||
Forbidden:
|
||||
|
||||
```typescript
|
||||
// ❌ Never use `as any`
|
||||
settings: testData as any
|
||||
|
||||
// ❌ Never modify production types to satisfy test errors
|
||||
// Don't add test settings to src/schemas/apiSchema.ts
|
||||
|
||||
// ❌ Don't chain through unknown to bypass types
|
||||
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
|
||||
data as unknown as SomeType
|
||||
```
|
||||
|
||||
### Accessing Internal State
|
||||
|
||||
When tests need internal store properties (e.g., `.workflow`, `.focusMode`):
|
||||
|
||||
```typescript
|
||||
// ✅ Access stores directly in page.evaluate
|
||||
await page.evaluate(() => {
|
||||
const store = useWorkflowStore()
|
||||
return store.activeWorkflow
|
||||
})
|
||||
|
||||
// ❌ Don't change public API types to expose internals
|
||||
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
|
||||
```
|
||||
Access internal state via `page.evaluate` and stores directly — don't change public API types to expose internals.
|
||||
|
||||
## Assertion Best Practices
|
||||
|
||||
When a test depends on an invariant unrelated to what it's actually testing (e.g. asserting a node has 4 widgets before testing node movement), always assert that invariant explicitly — don't leave it unchecked. Use a custom message or `expect.soft()` rather than a bare `expect`, so failures point to the broken assumption instead of producing a confusing error downstream.
|
||||
Assert preconditions explicitly with a custom message so failures point to the broken assumption:
|
||||
|
||||
```typescript
|
||||
// ✅ Custom message on an unrelated precondition — clear signal when the invariant breaks
|
||||
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
|
||||
4
|
||||
)
|
||||
await node.move(100, 200)
|
||||
|
||||
// ✅ Soft assertion — verifies multiple invariants without stopping the test early
|
||||
expect.soft(menuItem1).toBeVisible()
|
||||
expect.soft(menuItem2).toBeVisible()
|
||||
expect.soft(menuItem3).toBeVisible()
|
||||
|
||||
// ❌ Bare expect on a precondition — no context when it fails
|
||||
// Bad — bare expect on a precondition gives no context when it fails
|
||||
expect(node.widgets).toHaveLength(4)
|
||||
```
|
||||
|
||||
- Use custom messages (`expect(x, 'reason')`) for precondition checks unrelated to the test's purpose
|
||||
- Use `expect.soft()` when you want to verify multiple invariants without aborting on the first failure
|
||||
- Prefer Playwright's built-in message parameter over custom error classes
|
||||
- `expect(x, 'reason')` for precondition checks unrelated to the test's purpose
|
||||
- `expect.soft()` to verify multiple invariants without aborting on the first failure
|
||||
|
||||
## Test Structure: Arrange/Act/Assert
|
||||
|
||||
1. All mock setup, state resets, and fixture arrangement belongs in `test.beforeEach()` or Playwright fixtures
|
||||
2. Inside `test()`, only act (user actions) and assert
|
||||
3. Never call `clearAllMocks` or reset mock state mid-test
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('test.json')
|
||||
})
|
||||
test('should do something', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.click()
|
||||
await expect(comfyPage.menu.nodeLibraryTab.root).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
## Creating New Test Helpers
|
||||
|
||||
New domain-specific test helpers (e.g., `AssetHelper`, `JobHelper`) should be
|
||||
registered as Playwright fixtures via `base.extend()` rather than attached as
|
||||
properties on `ComfyPage`. This enables automatic setup/teardown.
|
||||
|
||||
### Extend `base` from Playwright
|
||||
|
||||
Keep each fixture self-contained by extending `@playwright/test` directly.
|
||||
Compose fixtures together with `mergeTests` when a test needs multiple helpers.
|
||||
|
||||
```typescript
|
||||
// browser_tests/fixtures/assetFixture.ts
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export const test = base.extend<{
|
||||
assetHelper: AssetHelper
|
||||
}>({
|
||||
assetHelper: async ({ page }, use) => {
|
||||
const helper = new AssetHelper(page)
|
||||
await helper.setup()
|
||||
await use(helper)
|
||||
await helper.cleanup() // automatic teardown
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- **Do NOT** add new helpers as properties on `ComfyPage`
|
||||
- Each fixture gets automatic cleanup via the callback after `use()`
|
||||
- Keep fixtures modular — extend `@playwright/test` base, not
|
||||
`comfyPageFixture`, so they can be composed via `mergeTests`
|
||||
|
||||
## Custom Assertions
|
||||
|
||||
Add assertion methods directly on the page object or helper class instead of extending `comfyExpect`. Page object methods are discoverable via IntelliSense without special imports.
|
||||
|
||||
```typescript
|
||||
// ✅ Page object assertions
|
||||
await node.expectPinned()
|
||||
await node.expectBypassed()
|
||||
|
||||
// ❌ Do not add custom matchers to comfyExpect
|
||||
```
|
||||
|
||||
## Test Tags
|
||||
|
||||
Tags are respected by config:
|
||||
|
||||
- `@mobile` - Mobile viewport tests
|
||||
- `@2x` - High DPI tests
|
||||
- `@mobile` — Mobile viewport tests
|
||||
- `@2x` — High DPI tests
|
||||
|
||||
## Test Data
|
||||
|
||||
- Check `browser_tests/assets/` for test data and fixtures
|
||||
- Use realistic ComfyUI workflows for E2E tests
|
||||
- When multiple nodes share the same title (e.g. two "CLIP Text Encode" nodes), use `vueNodes.getNodeByTitle(name).nth(n)` to pick a specific one. Never interact with the bare locator when titles are non-unique — Playwright strict mode will fail.
|
||||
- Check `browser_tests/assets/` for fixtures
|
||||
- Use realistic ComfyUI workflows
|
||||
- When multiple nodes share the same title, use `vueNodes.getNodeByTitle(name).nth(n)` — Playwright strict mode will fail on ambiguous locators
|
||||
|
||||
## Fixture Data & Schemas
|
||||
|
||||
When creating test fixture data, import or reference existing Zod schemas and TypeScript
|
||||
types from `src/` instead of inventing ad-hoc shapes. This keeps test data in sync with
|
||||
production types.
|
||||
|
||||
Key schema locations:
|
||||
|
||||
- `src/schemas/apiSchema.ts` — API response types (`PromptResponse`, `SystemStats`, `User`, `UserDataFullInfo`, WebSocket messages)
|
||||
- `src/schemas/nodeDefSchema.ts` — Node definition schema (`ComfyNodeDef`, `InputSpec`, `ComboInputSpec`)
|
||||
- `src/schemas/nodeDef/nodeDefSchemaV2.ts` — V2 node definition schema
|
||||
- `src/platform/remote/comfyui/jobs/jobTypes.ts` — Jobs API Zod schemas (`zJobDetail`, `zJobsListResponse`, `zRawJobListItem`)
|
||||
- `src/platform/workflow/validation/schemas/workflowSchema.ts` — Workflow validation (`ComfyWorkflowJSON`, `ComfyApiWorkflow`)
|
||||
- `src/types/metadataTypes.ts` — Asset metadata types
|
||||
|
||||
## Typed API Mocks
|
||||
|
||||
When mocking API responses with `route.fulfill()`, **always** type the response body
|
||||
using existing schemas or generated types — never use untyped inline JSON objects.
|
||||
This catches shape mismatches at compile time instead of through flaky runtime failures.
|
||||
|
||||
All three generated-type packages (`ingest-types`, `registry-types`, `generatedManagerTypes`)
|
||||
are auto-generated from their respective OpenAPI specs. Prefer these as the single
|
||||
source of truth for any mock that targets their endpoints.
|
||||
|
||||
### Sources of truth
|
||||
|
||||
| Endpoint category | Type source |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Cloud-only (hub, billing, workflows) | `@comfyorg/ingest-types` (`packages/ingest-types`, auto-generated from OpenAPI) |
|
||||
| Registry (releases, nodes, publishers) | `@comfyorg/registry-types` (`packages/registry-types`, auto-generated from OpenAPI) |
|
||||
| Manager (queue tasks, packages) | `generatedManagerTypes.ts` (`src/workbench/extensions/manager/types/`, auto-generated from OpenAPI) |
|
||||
| Python backend (queue, history, settings, features) | Manual Zod schemas in `src/schemas/apiSchema.ts` |
|
||||
| Node definitions | `src/schemas/nodeDefSchema.ts` |
|
||||
| Templates | `src/platform/workflow/templates/types/template.ts` |
|
||||
|
||||
### Patterns
|
||||
|
||||
```typescript
|
||||
// ✅ Import the type and annotate mock data
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
|
||||
const mockRelease: ReleaseNote = {
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
body: JSON.stringify([mockRelease])
|
||||
|
||||
// ❌ Untyped inline JSON — schema drift goes unnoticed
|
||||
body: JSON.stringify([{ id: 1, project: 'comfyui', version: 'v0.3.44', ... }])
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
|
||||
@@ -252,6 +252,22 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
// fixtures/data/ must contain only static data — no executable code or
|
||||
// Playwright imports. This enforces the architectural separation documented
|
||||
// in browser_tests/AGENTS.md.
|
||||
{
|
||||
files: ['browser_tests/fixtures/data/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'ImportDeclaration[source.value=/^@playwright/]',
|
||||
message:
|
||||
'fixtures/data/ must contain only static data. No Playwright imports allowed.'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['browser_tests/tests/**/*.test.ts'],
|
||||
rules: {
|
||||
@@ -392,6 +408,26 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
// Browser tests must use comfyPageFixture, not raw @playwright/test test
|
||||
{
|
||||
files: ['browser_tests/tests/**/*.spec.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@playwright/test',
|
||||
importNames: ['test'],
|
||||
message:
|
||||
"Use `comfyPageFixture as test` from the ComfyPage fixture module instead of raw `test` from '@playwright/test'."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Non-composable .ts files must use the global t/d/te, not useI18n()
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.8",
|
||||
"version": "1.43.10",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -8,6 +8,7 @@
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
@@ -44,6 +45,7 @@
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
@@ -56,6 +58,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
|
||||
@@ -9,27 +9,7 @@ export default defineConfig({
|
||||
parser: {
|
||||
filters: {
|
||||
operations: {
|
||||
// Exclude endpoints that overlap with ComfyUI Python backend.
|
||||
// These are shared between local and cloud, with separate Zod
|
||||
// schemas already maintained in src/schemas/apiSchema.ts.
|
||||
exclude: [
|
||||
'/^GET \\/api\\/prompt$/',
|
||||
'/^POST \\/api\\/prompt$/',
|
||||
'/^GET \\/api\\/queue$/',
|
||||
'/^POST \\/api\\/queue$/',
|
||||
'/^GET \\/api\\/history$/',
|
||||
'/^POST \\/api\\/history$/',
|
||||
'/^GET \\/api\\/history_v2/',
|
||||
'/^GET \\/api\\/object_info$/',
|
||||
'/^GET \\/api\\/features$/',
|
||||
'/^GET \\/api\\/settings$/',
|
||||
'/^POST \\/api\\/settings$/',
|
||||
'/^GET \\/api\\/system_stats$/',
|
||||
'/^(GET|POST) \\/api\\/interrupt$/',
|
||||
'/^POST \\/api\\/upload\\//',
|
||||
'/^GET \\/api\\/view$/',
|
||||
'/^GET \\/api\\/jobs/',
|
||||
'/\\/api\\/userdata/',
|
||||
// Webhooks are server-to-server, not called by frontend
|
||||
'/\\/api\\/webhooks\\//',
|
||||
// Internal analytics endpoint
|
||||
|
||||
@@ -145,6 +145,11 @@ export type {
|
||||
DeleteSessionResponse,
|
||||
DeleteSessionResponse2,
|
||||
DeleteSessionResponses,
|
||||
DeleteUserdataFileData,
|
||||
DeleteUserdataFileError,
|
||||
DeleteUserdataFileErrors,
|
||||
DeleteUserdataFileResponse,
|
||||
DeleteUserdataFileResponses,
|
||||
DeleteWorkflowData,
|
||||
DeleteWorkflowError,
|
||||
DeleteWorkflowErrors,
|
||||
@@ -170,6 +175,12 @@ export type {
|
||||
ExchangeTokenResponse,
|
||||
ExchangeTokenResponse2,
|
||||
ExchangeTokenResponses,
|
||||
ExecutePromptData,
|
||||
ExecutePromptError,
|
||||
ExecutePromptErrors,
|
||||
ExecutePromptResponse,
|
||||
ExecutePromptResponses,
|
||||
ExecutionError,
|
||||
ExportDownloadUrlResponse,
|
||||
FeedbackRequest,
|
||||
FeedbackResponse,
|
||||
@@ -180,6 +191,11 @@ export type {
|
||||
ForkWorkflowRequest,
|
||||
ForkWorkflowResponse,
|
||||
ForkWorkflowResponses,
|
||||
GetAllSettingsData,
|
||||
GetAllSettingsError,
|
||||
GetAllSettingsErrors,
|
||||
GetAllSettingsResponse,
|
||||
GetAllSettingsResponses,
|
||||
GetAssetByIdData,
|
||||
GetAssetByIdError,
|
||||
GetAssetByIdErrors,
|
||||
@@ -220,6 +236,9 @@ export type {
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
GetFeaturesResponses,
|
||||
GetFilesData,
|
||||
GetFilesError,
|
||||
GetFilesErrors,
|
||||
@@ -235,6 +254,16 @@ export type {
|
||||
GetGlobalSubgraphsErrors,
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
GetHistoryForPromptData,
|
||||
GetHistoryForPromptError,
|
||||
GetHistoryForPromptErrors,
|
||||
GetHistoryForPromptResponse,
|
||||
GetHistoryForPromptResponses,
|
||||
GetHistoryResponse,
|
||||
GetHistoryResponses,
|
||||
GetHubProfileByUsernameData,
|
||||
GetHubProfileByUsernameError,
|
||||
GetHubProfileByUsernameErrors,
|
||||
@@ -250,6 +279,11 @@ export type {
|
||||
GetInviteCodeStatusErrors,
|
||||
GetInviteCodeStatusResponse,
|
||||
GetInviteCodeStatusResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
GetJobDetailResponse,
|
||||
GetJobDetailResponses,
|
||||
GetJobStatusData,
|
||||
GetJobStatusError,
|
||||
GetJobStatusErrors,
|
||||
@@ -288,16 +322,29 @@ export type {
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetNodeInfoData,
|
||||
GetNodeInfoResponse,
|
||||
GetNodeInfoResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPromptInfoData,
|
||||
GetPromptInfoError,
|
||||
GetPromptInfoErrors,
|
||||
GetPromptInfoResponse,
|
||||
GetPromptInfoResponses,
|
||||
GetPublishedWorkflowData,
|
||||
GetPublishedWorkflowError,
|
||||
GetPublishedWorkflowErrors,
|
||||
GetPublishedWorkflowResponse,
|
||||
GetPublishedWorkflowResponses,
|
||||
GetQueueInfoData,
|
||||
GetQueueInfoError,
|
||||
GetQueueInfoErrors,
|
||||
GetQueueInfoResponse,
|
||||
GetQueueInfoResponses,
|
||||
GetRawLogsData,
|
||||
GetRawLogsError,
|
||||
GetRawLogsErrors,
|
||||
@@ -318,12 +365,34 @@ export type {
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetSystemStatsData,
|
||||
GetSystemStatsError,
|
||||
GetSystemStatsErrors,
|
||||
GetSystemStatsResponse,
|
||||
GetSystemStatsResponses,
|
||||
GetTaskData,
|
||||
GetTaskError,
|
||||
GetTaskErrors,
|
||||
GetTaskResponse,
|
||||
GetTaskResponses,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
GetUserdataErrors,
|
||||
GetUserdataFileData,
|
||||
GetUserdataFileError,
|
||||
GetUserdataFileErrors,
|
||||
GetUserdataFilePublishData,
|
||||
GetUserdataFilePublishError,
|
||||
GetUserdataFilePublishErrors,
|
||||
GetUserdataFilePublishResponse,
|
||||
GetUserdataFilePublishResponses,
|
||||
GetUserdataFileResponse,
|
||||
GetUserdataFileResponses,
|
||||
GetUserdataResponse,
|
||||
GetUserDataResponseFull,
|
||||
GetUserDataResponseFullFile,
|
||||
GetUserdataResponses,
|
||||
GetUserError,
|
||||
GetUserErrors,
|
||||
GetUserResponse,
|
||||
@@ -348,6 +417,11 @@ export type {
|
||||
GetWorkspaceResponses,
|
||||
GlobalSubgraphData,
|
||||
GlobalSubgraphInfo,
|
||||
HistoryDetailEntry,
|
||||
HistoryDetailResponse,
|
||||
HistoryEntry,
|
||||
HistoryManageRequest,
|
||||
HistoryResponse,
|
||||
HubAssetUploadUrlRequest,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
@@ -367,8 +441,15 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
JwksResponse,
|
||||
@@ -401,6 +482,11 @@ export type {
|
||||
ListHubWorkflowsResponse,
|
||||
ListHubWorkflowsResponses,
|
||||
ListInvitesResponse,
|
||||
ListJobsData,
|
||||
ListJobsError,
|
||||
ListJobsErrors,
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
@@ -441,9 +527,24 @@ export type {
|
||||
ListWorkspacesResponses,
|
||||
LogsResponse,
|
||||
LogsSubscribeRequest,
|
||||
ManageHistoryData,
|
||||
ManageHistoryError,
|
||||
ManageHistoryErrors,
|
||||
ManageHistoryResponses,
|
||||
ManageQueueData,
|
||||
ManageQueueError,
|
||||
ManageQueueErrors,
|
||||
ManageQueueResponse,
|
||||
ManageQueueResponses,
|
||||
Member,
|
||||
ModelFile,
|
||||
ModelFolder,
|
||||
MoveUserdataFileData,
|
||||
MoveUserdataFileError,
|
||||
MoveUserdataFileErrors,
|
||||
MoveUserdataFileResponse,
|
||||
MoveUserdataFileResponses,
|
||||
NodeInfo,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
@@ -459,6 +560,16 @@ export type {
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PostUserdataFileData,
|
||||
PostUserdataFileError,
|
||||
PostUserdataFileErrors,
|
||||
PostUserdataFilePublishData,
|
||||
PostUserdataFilePublishError,
|
||||
PostUserdataFilePublishErrors,
|
||||
PostUserdataFilePublishResponse,
|
||||
PostUserdataFilePublishResponses,
|
||||
PostUserdataFileResponse,
|
||||
PostUserdataFileResponses,
|
||||
PreviewPlanInfo,
|
||||
PreviewSubscribeData,
|
||||
PreviewSubscribeError,
|
||||
@@ -467,6 +578,10 @@ export type {
|
||||
PreviewSubscribeResponse,
|
||||
PreviewSubscribeResponse2,
|
||||
PreviewSubscribeResponses,
|
||||
PromptErrorResponse,
|
||||
PromptInfo,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
@@ -474,6 +589,10 @@ export type {
|
||||
PublishHubWorkflowRequest,
|
||||
PublishHubWorkflowResponse,
|
||||
PublishHubWorkflowResponses,
|
||||
PublishWorkflowAssetsRequest,
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RawLogsResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
@@ -537,6 +656,7 @@ export type {
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SystemStatsResponse,
|
||||
TagInfo,
|
||||
TagsModificationResponse,
|
||||
TaskEntry,
|
||||
@@ -558,6 +678,11 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
UpdateMultipleSettingsResponse,
|
||||
UpdateMultipleSettingsResponses,
|
||||
UpdateSecretData,
|
||||
UpdateSecretError,
|
||||
UpdateSecretErrors,
|
||||
@@ -586,13 +711,30 @@ export type {
|
||||
UploadAssetErrors,
|
||||
UploadAssetResponse,
|
||||
UploadAssetResponses,
|
||||
UploadImageData,
|
||||
UploadImageError,
|
||||
UploadImageErrors,
|
||||
UploadImageResponse,
|
||||
UploadImageResponses,
|
||||
UploadMaskData,
|
||||
UploadMaskError,
|
||||
UploadMaskErrors,
|
||||
UploadMaskResponse,
|
||||
UploadMaskResponses,
|
||||
UserDataResponseFull,
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
ViewFileResponse,
|
||||
ViewFileResponses,
|
||||
WorkflowApiAssetsRequest,
|
||||
WorkflowApiAssetsResponse,
|
||||
WorkflowForkedFrom,
|
||||
WorkflowListResponse,
|
||||
WorkflowPublishInfo,
|
||||
WorkflowResponse,
|
||||
WorkflowVersionContentResponse,
|
||||
WorkflowVersionResponse,
|
||||
|
||||
1658
packages/ingest-types/src/types.gen.ts
generated
1658
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
664
packages/ingest-types/src/zod.gen.ts
generated
664
packages/ingest-types/src/zod.gen.ts
generated
@@ -216,6 +216,18 @@ export const zWorkflowApiAssetsRequest = z.object({
|
||||
workflow_api_json: z.record(z.unknown())
|
||||
})
|
||||
|
||||
export const zPublishWorkflowAssetsRequest = z.object({
|
||||
asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
export const zWorkflowPublishInfo = z.object({
|
||||
workflow_id: z.string(),
|
||||
share_id: z.string(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
listed: z.boolean(),
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zForkWorkflowRequest = z.object({
|
||||
source_version: z.number().int(),
|
||||
name: z.string().optional()
|
||||
@@ -756,6 +768,106 @@ export const zDeletionRequest = z.object({
|
||||
deletion_status: z.array(zDeletionStatus)
|
||||
})
|
||||
|
||||
/**
|
||||
* Detailed execution error information from ComfyUI
|
||||
*/
|
||||
export const zExecutionError = z.object({
|
||||
node_id: z.string(),
|
||||
node_type: z.string(),
|
||||
exception_message: z.string(),
|
||||
exception_type: z.string(),
|
||||
traceback: z.array(z.string()),
|
||||
current_inputs: z.record(z.unknown()),
|
||||
current_outputs: z.record(z.unknown())
|
||||
})
|
||||
|
||||
/**
|
||||
* Full job details including workflow and outputs
|
||||
*/
|
||||
export const zJobDetailResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
]),
|
||||
workflow: z.record(z.unknown()).optional(),
|
||||
execution_error: zExecutionError.optional(),
|
||||
create_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
update_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
outputs: z.record(z.unknown()).optional(),
|
||||
preview_output: z.record(z.unknown()).optional(),
|
||||
outputs_count: z.number().int().optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
execution_status: z.record(z.unknown()).optional(),
|
||||
execution_meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Lightweight job data for list views (workflow and full outputs excluded)
|
||||
*/
|
||||
export const zJobEntry = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
]),
|
||||
execution_error: zExecutionError.optional(),
|
||||
create_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
preview_output: z.record(z.unknown()).optional(),
|
||||
outputs_count: z.number().int().optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
execution_start_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
execution_end_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zJobsListResponse = z.object({
|
||||
jobs: z.array(zJobEntry),
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
export const zTagsModificationResponse = z.object({
|
||||
added: z.array(z.string()).optional(),
|
||||
removed: z.array(z.string()).optional(),
|
||||
@@ -931,6 +1043,33 @@ export const zUserResponse = z.object({
|
||||
status: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* System statistics response
|
||||
*/
|
||||
export const zSystemStatsResponse = z.object({
|
||||
system: z.object({
|
||||
os: z.string(),
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
cloud_version: z.string().optional(),
|
||||
pytorch_version: z.string(),
|
||||
argv: z.array(z.string()),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number()
|
||||
}),
|
||||
devices: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
vram_total: z.number().optional(),
|
||||
vram_free: z.number().optional()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
export const zLogsSubscribeRequest = z.object({
|
||||
enabled: z.boolean()
|
||||
})
|
||||
@@ -998,6 +1137,49 @@ export const zModelFolder = z.object({
|
||||
folders: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Error response for ComfyUI prompt execution.
|
||||
*/
|
||||
export const zPromptErrorResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetUserDataResponseFullFile = z.object({
|
||||
path: z.string().optional(),
|
||||
size: z.number().int().optional(),
|
||||
modified: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zGetUserDataResponseFull = z.array(zGetUserDataResponseFullFile)
|
||||
|
||||
export const zUserDataResponseFull = z.object({
|
||||
path: z.string().optional(),
|
||||
size: z.number().int().optional(),
|
||||
modified: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to manage history operations
|
||||
*/
|
||||
export const zHistoryManageRequest = z.object({
|
||||
delete: z.array(z.string()).optional(),
|
||||
clear: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Job status information
|
||||
*/
|
||||
@@ -1018,6 +1200,89 @@ export const zJobStatusResponse = z.object({
|
||||
error_message: z.string().nullish()
|
||||
})
|
||||
|
||||
export const zQueueManageResponse = z.object({
|
||||
deleted: z.array(z.string()).optional(),
|
||||
cleared: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to manage queue operations
|
||||
*/
|
||||
export const zQueueManageRequest = z.object({
|
||||
delete: z.array(z.string()).optional(),
|
||||
clear: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Queue information with pending and running jobs
|
||||
*/
|
||||
export const zQueueInfo = z.object({
|
||||
queue_running: z.array(z.array(z.unknown())).optional(),
|
||||
queue_pending: z.array(z.array(z.unknown())).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* History entry with full prompt data
|
||||
*/
|
||||
export const zHistoryDetailEntry = z.object({
|
||||
prompt: z
|
||||
.object({
|
||||
priority: z.number().optional(),
|
||||
prompt_id: z.string().optional(),
|
||||
prompt: z.record(z.unknown()).optional(),
|
||||
extra_data: z.record(z.unknown()).optional(),
|
||||
outputs_to_execute: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
outputs: z.record(z.unknown()).optional(),
|
||||
status: z.record(z.unknown()).optional(),
|
||||
meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Detailed execution history response for a specific prompt.
|
||||
* Returns a dictionary with prompt_id as key and full history data as value.
|
||||
*
|
||||
*/
|
||||
export const zHistoryDetailResponse = z.record(zHistoryDetailEntry)
|
||||
|
||||
/**
|
||||
* History entry with prompt_id and execution data
|
||||
*/
|
||||
export const zHistoryEntry = z.object({
|
||||
prompt_id: z.string(),
|
||||
create_time: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
prompt: z
|
||||
.object({
|
||||
priority: z.number().optional(),
|
||||
prompt_id: z.string().optional(),
|
||||
extra_data: z.record(z.unknown()).optional()
|
||||
})
|
||||
.optional(),
|
||||
outputs: z.record(z.unknown()).optional(),
|
||||
status: z.record(z.unknown()).optional(),
|
||||
meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Execution history response with history array.
|
||||
* Returns an object with a "history" key containing an array of history entries.
|
||||
* Each entry includes prompt_id as a property along with execution data.
|
||||
*
|
||||
*/
|
||||
export const zHistoryResponse = z.object({
|
||||
history: z.array(zHistoryEntry)
|
||||
})
|
||||
|
||||
/**
|
||||
* Full data for a global subgraph blueprint
|
||||
*/
|
||||
@@ -1042,6 +1307,32 @@ export const zGlobalSubgraphInfo = z.object({
|
||||
data: z.string().optional()
|
||||
})
|
||||
|
||||
export const zNodeInfo = z.object({
|
||||
input: z.record(z.unknown()).optional(),
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
output: z.array(z.string()).optional(),
|
||||
output_is_list: z.array(z.boolean()).optional(),
|
||||
output_name: z.array(z.string()).optional(),
|
||||
name: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
python_module: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
output_node: z.boolean().optional(),
|
||||
output_tooltips: z.array(z.string()).optional(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
api_node: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zPromptInfo = z.object({
|
||||
exec_info: z
|
||||
.object({
|
||||
queue_remaining: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zExportDownloadUrlResponse = z.object({
|
||||
url: z.string(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
@@ -1052,6 +1343,22 @@ export const zErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
export const zPromptResponse = z.object({
|
||||
prompt_id: z.string().uuid().optional(),
|
||||
number: z.number().optional(),
|
||||
node_errors: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
export const zPromptRequest = z.object({
|
||||
prompt: z.record(z.unknown()),
|
||||
number: z.number().optional(),
|
||||
front: z.boolean().optional(),
|
||||
extra_data: z.record(z.unknown()).optional(),
|
||||
partial_execution_targets: z.array(z.string()).optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
workflow_version_id: z.string().optional()
|
||||
})
|
||||
|
||||
export const zAssetWritable = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
@@ -1096,6 +1403,53 @@ export const zAssetCreatedWritable = zAssetWritable.and(
|
||||
*/
|
||||
export const zFeedbackResponseWritable = z.record(z.unknown())
|
||||
|
||||
export const zGetPromptInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetPromptInfoResponse = zPromptInfo
|
||||
|
||||
export const zExecutePromptData = z.object({
|
||||
body: zPromptRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Prompt accepted
|
||||
*/
|
||||
export const zExecutePromptResponse = zPromptResponse
|
||||
|
||||
export const zGetNodeInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetNodeInfoResponse = z.record(zNodeInfo)
|
||||
|
||||
export const zGetFeaturesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetFeaturesResponse = z.object({
|
||||
supports_preview_metadata: z.boolean().optional(),
|
||||
max_upload_size: z.number().int().optional()
|
||||
})
|
||||
|
||||
export const zGetWorkflowTemplatesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1170,6 +1524,97 @@ export const zGetModelPreviewData = z.object({
|
||||
*/
|
||||
export const zGetModelPreviewResponse = z.string()
|
||||
|
||||
export const zManageHistoryData = z.object({
|
||||
body: zHistoryManageRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetHistoryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
max_items: z.number().int().optional(),
|
||||
offset: z.number().int().optional().default(0)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Execution history retrieved
|
||||
*/
|
||||
export const zGetHistoryResponse = zHistoryResponse
|
||||
|
||||
export const zGetHistoryForPromptData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
prompt_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - History for prompt retrieved
|
||||
*/
|
||||
export const zGetHistoryForPromptResponse = zHistoryDetailResponse
|
||||
|
||||
export const zListJobsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
status: z.string().optional(),
|
||||
workflow_id: z.string().optional(),
|
||||
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
|
||||
sort_by: z.enum(['create_time', 'execution_time']).optional(),
|
||||
sort_order: z.enum(['asc', 'desc']).optional(),
|
||||
offset: z.number().int().gte(0).optional().default(0),
|
||||
limit: z.number().int().gte(1).lte(1000).optional().default(100)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Jobs retrieved
|
||||
*/
|
||||
export const zListJobsResponse = zJobsListResponse
|
||||
|
||||
export const zGetJobDetailData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
job_id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Job details retrieved
|
||||
*/
|
||||
export const zGetJobDetailResponse = zJobDetailResponse
|
||||
|
||||
export const zViewFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
fullpath: z.string().optional(),
|
||||
format: z.string().optional(),
|
||||
frame_rate: z.number().int().optional(),
|
||||
workflow: z.string().optional(),
|
||||
timestamp: z.number().int().optional(),
|
||||
channel: z.string().optional(),
|
||||
res: z.number().int().gte(64).lte(1024).optional()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - File content returned (used when channel or res parameter is present)
|
||||
*/
|
||||
export const zViewFileResponse = z.string()
|
||||
|
||||
export const zGetMaskLayersData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1482,6 +1927,34 @@ export const zImportPublishedAssetsData = z.object({
|
||||
*/
|
||||
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
|
||||
|
||||
export const zGetQueueInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetQueueInfoResponse = zQueueInfo
|
||||
|
||||
export const zManageQueueData = z.object({
|
||||
body: zQueueManageRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zManageQueueResponse = zQueueManageResponse
|
||||
|
||||
export const zInterruptJobData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zListSecretsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1543,6 +2016,28 @@ export const zUpdateSecretData = z.object({
|
||||
*/
|
||||
export const zUpdateSecretResponse = zSecretResponse
|
||||
|
||||
export const zGetAllSettingsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* User settings as key-value pairs
|
||||
*/
|
||||
export const zGetAllSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zUpdateMultipleSettingsData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Updated user settings
|
||||
*/
|
||||
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetSettingByKeyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -1584,6 +2079,164 @@ export const zSubmitFeedbackData = z.object({
|
||||
*/
|
||||
export const zSubmitFeedbackResponse = zFeedbackResponse
|
||||
|
||||
export const zGetUserdataData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
dir: z.string().optional(),
|
||||
recurse: z.boolean().optional().default(false),
|
||||
split: z.boolean().optional().default(false),
|
||||
full_info: z.boolean().optional().default(false)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* A list of user data files.
|
||||
*/
|
||||
export const zGetUserdataResponse = zGetUserDataResponseFull
|
||||
|
||||
export const zGetUserdataFilePublishData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Publish info (publish_time is null if never published)
|
||||
*/
|
||||
export const zGetUserdataFilePublishResponse = zWorkflowPublishInfo
|
||||
|
||||
export const zPostUserdataFilePublishData = z.object({
|
||||
body: zPublishWorkflowAssetsRequest,
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow published
|
||||
*/
|
||||
export const zPostUserdataFilePublishResponse = zWorkflowPublishInfo
|
||||
|
||||
export const zDeleteUserdataFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* File deleted successfully (No Content).
|
||||
*/
|
||||
export const zDeleteUserdataFileResponse = z.void()
|
||||
|
||||
export const zGetUserdataFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully retrieved the file.
|
||||
*/
|
||||
export const zGetUserdataFileResponse = z.string()
|
||||
|
||||
export const zPostUserdataFileData = z.object({
|
||||
body: z.string(),
|
||||
path: z.object({
|
||||
file: z.string()
|
||||
}),
|
||||
query: z
|
||||
.object({
|
||||
overwrite: z.enum(['true', 'false']).optional(),
|
||||
full_info: z.enum(['true', 'false']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* File uploaded successfully.
|
||||
*/
|
||||
export const zPostUserdataFileResponse = zUserDataResponseFull
|
||||
|
||||
export const zMoveUserdataFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
file: z.string(),
|
||||
dest: z.string()
|
||||
}),
|
||||
query: z
|
||||
.object({
|
||||
overwrite: z.enum(['true', 'false']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* File moved successfully.
|
||||
*/
|
||||
export const zMoveUserdataFileResponse = zUserDataResponseFull
|
||||
|
||||
export const zUploadImageData = z.object({
|
||||
body: z.object({
|
||||
image: z.string(),
|
||||
overwrite: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Image uploaded successfully
|
||||
*/
|
||||
export const zUploadImageResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
})
|
||||
|
||||
export const zUploadMaskData = z.object({
|
||||
body: z.object({
|
||||
image: z.string(),
|
||||
original_ref: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Mask uploaded successfully
|
||||
*/
|
||||
export const zUploadMaskResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
is_mask: z.boolean().optional(),
|
||||
original_hash: z.string().optional(),
|
||||
mask_type: z.string().optional(),
|
||||
related_files: z
|
||||
.object({
|
||||
mask: z.string().optional(),
|
||||
paint: z.string().optional(),
|
||||
painted: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zGetLogsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1619,6 +2272,17 @@ export const zSubscribeToLogsResponse = z.object({
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zGetSystemStatsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetSystemStatsResponse = zSystemStatsResponse
|
||||
|
||||
export const zDeleteSessionData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
|
||||
@@ -36,7 +36,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grepInvert: /@mobile|@perf|@audit/ // Run all tests except those tagged with @mobile, @perf, or @audit
|
||||
grepInvert: /@mobile|@perf|@audit|@cloud/ // Run all tests except those tagged with @mobile, @perf, @audit, or @cloud
|
||||
},
|
||||
|
||||
{
|
||||
@@ -85,6 +85,14 @@ export default defineConfig({
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
{
|
||||
name: 'cloud',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grep: /@cloud/, // Run only tests tagged with @cloud
|
||||
grepInvert: /@oss/ // Exclude tests tagged with @oss
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -410,6 +410,9 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/ingest-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/ingest-types
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
|
||||
@@ -29,6 +29,8 @@ interface PerfMeasurement {
|
||||
eventListeners: number
|
||||
totalBlockingTimeMs: number
|
||||
frameDurationMs: number
|
||||
p95FrameDurationMs: number
|
||||
allFrameDurationsMs?: number[]
|
||||
}
|
||||
|
||||
interface PerfReport {
|
||||
@@ -53,6 +55,7 @@ type MetricKey =
|
||||
| 'eventListeners'
|
||||
| 'totalBlockingTimeMs'
|
||||
| 'frameDurationMs'
|
||||
| 'p95FrameDurationMs'
|
||||
| 'heapUsedBytes'
|
||||
|
||||
interface MetricDef {
|
||||
@@ -64,6 +67,8 @@ interface MetricDef {
|
||||
}
|
||||
|
||||
const REPORTED_METRICS: MetricDef[] = [
|
||||
{ key: 'frameDurationMs', label: 'avg frame time', unit: 'ms' },
|
||||
{ key: 'p95FrameDurationMs', label: 'p95 frame time', unit: 'ms' },
|
||||
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
|
||||
{
|
||||
key: 'styleRecalcDurationMs',
|
||||
@@ -80,12 +85,15 @@ const REPORTED_METRICS: MetricDef[] = [
|
||||
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
|
||||
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
|
||||
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
|
||||
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
|
||||
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
|
||||
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
|
||||
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
|
||||
]
|
||||
|
||||
/** Target: P5 FPS ≥ 52 → P95 frame time ≤ 19.2ms */
|
||||
const TARGET_P95_FRAME_MS = 19.2
|
||||
const TARGET_P5_FPS = 52
|
||||
|
||||
function groupByName(
|
||||
measurements: PerfMeasurement[]
|
||||
): Map<string, PerfMeasurement[]> {
|
||||
@@ -207,6 +215,46 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function frameTimeToFps(ms: number): number {
|
||||
return ms > 0 ? 1000 / ms : 0
|
||||
}
|
||||
|
||||
function renderHeadlineSummary(
|
||||
prGroups: Map<string, PerfMeasurement[]>
|
||||
): string[] {
|
||||
const lines: string[] = []
|
||||
const summaries: string[] = []
|
||||
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
const avgFrame = medianMetric(prSamples, 'frameDurationMs')
|
||||
const p95Frame = medianMetric(prSamples, 'p95FrameDurationMs')
|
||||
const tbt = medianMetric(prSamples, 'totalBlockingTimeMs')
|
||||
const heap = medianMetric(prSamples, 'heapUsedBytes')
|
||||
|
||||
const avgFps = avgFrame !== null ? frameTimeToFps(avgFrame) : null
|
||||
const p5Fps = p95Frame !== null ? frameTimeToFps(p95Frame) : null
|
||||
|
||||
const parts: string[] = [`**${testName}**:`]
|
||||
if (avgFps !== null) parts.push(`${avgFps.toFixed(1)} avg FPS`)
|
||||
if (p5Fps !== null) {
|
||||
const pass = p5Fps >= TARGET_P5_FPS
|
||||
parts.push(
|
||||
`${p5Fps.toFixed(1)} P5 FPS ${pass ? '✅' : '❌'} (target: ≥${TARGET_P5_FPS})`
|
||||
)
|
||||
}
|
||||
if (tbt !== null) parts.push(`${tbt.toFixed(0)}ms TBT`)
|
||||
if (heap !== null) parts.push(`${formatBytes(heap)} heap`)
|
||||
|
||||
if (parts.length > 1) summaries.push(parts.join(' · '))
|
||||
}
|
||||
|
||||
if (summaries.length > 0) {
|
||||
lines.push('> ' + summaries.join('\n> '), '')
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function renderFullReport(
|
||||
prGroups: Map<string, PerfMeasurement[]>,
|
||||
baseline: PerfReport,
|
||||
@@ -423,6 +471,7 @@ function main() {
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('## ⚡ Performance Report\n')
|
||||
lines.push(...renderHeadlineSummary(prGroups))
|
||||
|
||||
if (baseline && historical.length >= 2) {
|
||||
lines.push(...renderFullReport(prGroups, baseline, historical))
|
||||
@@ -432,9 +481,15 @@ function main() {
|
||||
lines.push(...renderNoBaselineReport(prGroups))
|
||||
}
|
||||
|
||||
const rawData = {
|
||||
...current,
|
||||
measurements: current.measurements.map(
|
||||
({ allFrameDurationsMs: _, ...rest }) => rest
|
||||
)
|
||||
}
|
||||
lines.push('\n<details><summary>Raw data</summary>\n')
|
||||
lines.push('```json')
|
||||
lines.push(JSON.stringify(current, null, 2))
|
||||
lines.push(JSON.stringify(rawData, null, 2))
|
||||
lines.push('```')
|
||||
lines.push('\n</details>')
|
||||
|
||||
|
||||
@@ -2258,6 +2258,7 @@
|
||||
"dataset": "مجموعة بيانات",
|
||||
"debug": "تصحيح",
|
||||
"deprecated": "مهمل",
|
||||
"detection": "الكشف",
|
||||
"edit_models": "تحرير النماذج",
|
||||
"flux": "تدفق",
|
||||
"gligen": "gligen",
|
||||
@@ -3239,6 +3240,7 @@
|
||||
"termsAgreement": "بالمتابعة، أنت توافق على {terms} و{privacy} الخاصة بـ Comfy Org.",
|
||||
"totalDueToday": "الإجمالي المستحق اليوم"
|
||||
},
|
||||
"refreshCredits": "تحديث الرصيد",
|
||||
"renewsDate": "تجديد في {date}",
|
||||
"required": {
|
||||
"pollingFailed": "فشل تفعيل الاشتراك",
|
||||
|
||||
@@ -2190,6 +2190,10 @@
|
||||
"image": {
|
||||
"name": "الصورة"
|
||||
},
|
||||
"keep_aspect": {
|
||||
"name": "keep_aspect",
|
||||
"tooltip": "ما إذا كان سيتم تمديد الاقتصاص ليتناسب مع حجم الإخراج، أو إضافة حواف سوداء للحفاظ على نسبة العرض إلى الارتفاع."
|
||||
},
|
||||
"output_height": {
|
||||
"name": "ارتفاع الناتج",
|
||||
"tooltip": "الارتفاع الذي يتم تغيير حجم كل قص إليه."
|
||||
@@ -2313,6 +2317,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DrawBBoxes": {
|
||||
"display_name": "رسم مربعات الإحاطة",
|
||||
"inputs": {
|
||||
"bboxes": {
|
||||
"name": "bboxes"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "out_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"DualCFGGuider": {
|
||||
"display_name": "موجّه CFG مزدوج",
|
||||
"inputs": {
|
||||
@@ -12226,6 +12247,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RTDETR_detect": {
|
||||
"display_name": "اكتشاف RT-DETR",
|
||||
"inputs": {
|
||||
"class_name": {
|
||||
"name": "class_name",
|
||||
"tooltip": "تصفية الاكتشافات حسب الفئة. اختر 'all' لتعطيل التصفية."
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"max_detections": {
|
||||
"name": "max_detections",
|
||||
"tooltip": "الحد الأقصى لعدد الاكتشافات التي سيتم إرجاعها لكل صورة. بالترتيب التنازلي حسب درجة الثقة."
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"threshold": {
|
||||
"name": "threshold"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "bboxes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomCropImages": {
|
||||
"display_name": "قص عشوائي للصور",
|
||||
"inputs": {
|
||||
@@ -15631,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "صورة UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1678,6 +1678,8 @@
|
||||
"scheduling": "scheduling",
|
||||
"create": "create",
|
||||
"deprecated": "deprecated",
|
||||
"detection": "detection",
|
||||
"": "",
|
||||
"debug": "debug",
|
||||
"model": "model",
|
||||
"ElevenLabs": "ElevenLabs",
|
||||
@@ -1688,7 +1690,6 @@
|
||||
"unet": "unet",
|
||||
"sigmas": "sigmas",
|
||||
"BFL": "BFL",
|
||||
"": "",
|
||||
"Gemini": "Gemini",
|
||||
"video_models": "video_models",
|
||||
"gligen": "gligen",
|
||||
|
||||
@@ -2201,6 +2201,10 @@
|
||||
"padding": {
|
||||
"name": "padding",
|
||||
"tooltip": "Extra padding in pixels added on each side of the bbox before cropping."
|
||||
},
|
||||
"keep_aspect": {
|
||||
"name": "keep_aspect",
|
||||
"tooltip": "Whether to stretch the crop to fit the output size, or pad with black pixels to preserve aspect ratio."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -2313,6 +2317,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DrawBBoxes": {
|
||||
"display_name": "Draw BBoxes",
|
||||
"inputs": {
|
||||
"bboxes": {
|
||||
"name": "bboxes"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "out_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"DualCFGGuider": {
|
||||
"display_name": "DualCFGGuider",
|
||||
"inputs": {
|
||||
@@ -13417,6 +13438,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RTDETR_detect": {
|
||||
"display_name": "RT-DETR Detect",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"threshold": {
|
||||
"name": "threshold"
|
||||
},
|
||||
"class_name": {
|
||||
"name": "class_name",
|
||||
"tooltip": "Filter detections by class. Set to 'all' to disable filtering."
|
||||
},
|
||||
"max_detections": {
|
||||
"name": "max_detections",
|
||||
"tooltip": "Maximum number of detections to return per image. In order of descending confidence score."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "bboxes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunwayFirstLastFrameNode": {
|
||||
"display_name": "Runway First-Last-Frame to Video",
|
||||
"description": "Upload first and last keyframes, draft a prompt, and generate a video. More complex transitions, such as cases where the Last frame is completely different from the First frame, may benefit from the longer 10s duration. This would give the generation more time to smoothly transition between the two inputs. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3.",
|
||||
@@ -15631,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2258,6 +2258,7 @@
|
||||
"dataset": "conjunto de datos",
|
||||
"debug": "depurar",
|
||||
"deprecated": "obsoleto",
|
||||
"detection": "detección",
|
||||
"edit_models": "editar_modelos",
|
||||
"flux": "flux",
|
||||
"gligen": "gligen",
|
||||
@@ -3239,6 +3240,7 @@
|
||||
"termsAgreement": "Al continuar, aceptas los {terms} y la {privacy} de Comfy Org.",
|
||||
"totalDueToday": "Total a pagar hoy"
|
||||
},
|
||||
"refreshCredits": "Actualizar créditos",
|
||||
"renewsDate": "Se renueva el {date}",
|
||||
"required": {
|
||||
"pollingFailed": "Error al activar la suscripción",
|
||||
|
||||
@@ -2190,6 +2190,10 @@
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
},
|
||||
"keep_aspect": {
|
||||
"name": "mantener_proporción",
|
||||
"tooltip": "Indica si se debe estirar el recorte para ajustarse al tamaño de salida, o rellenar con píxeles negros para preservar la relación de aspecto."
|
||||
},
|
||||
"output_height": {
|
||||
"name": "alto de salida",
|
||||
"tooltip": "Alto al que se redimensiona cada recorte."
|
||||
@@ -2313,6 +2317,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DrawBBoxes": {
|
||||
"display_name": "Dibujar BBoxes",
|
||||
"inputs": {
|
||||
"bboxes": {
|
||||
"name": "bboxes"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "imagen_salida",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"DualCFGGuider": {
|
||||
"display_name": "Guía Dual CFG",
|
||||
"inputs": {
|
||||
@@ -12226,6 +12247,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RTDETR_detect": {
|
||||
"display_name": "Detección RT-DETR",
|
||||
"inputs": {
|
||||
"class_name": {
|
||||
"name": "nombre_clase",
|
||||
"tooltip": "Filtrar detecciones por clase. Establecer en 'all' para desactivar el filtrado."
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
},
|
||||
"max_detections": {
|
||||
"name": "máx_detecciones",
|
||||
"tooltip": "Número máximo de detecciones a devolver por imagen. En orden descendente de puntuación de confianza."
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo"
|
||||
},
|
||||
"threshold": {
|
||||
"name": "umbral"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "bboxes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomCropImages": {
|
||||
"display_name": "Recorte Aleatorio de Imágenes",
|
||||
"inputs": {
|
||||
@@ -15631,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "imagen UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2258,6 +2258,7 @@
|
||||
"dataset": "دادهنما",
|
||||
"debug": "اشکالزدایی",
|
||||
"deprecated": "منسوخ",
|
||||
"detection": "شناسایی",
|
||||
"edit_models": "ویرایش مدلها",
|
||||
"flux": "flux",
|
||||
"gligen": "gligen",
|
||||
@@ -3251,6 +3252,7 @@
|
||||
"termsAgreement": "با ادامه، شما با {terms} و {privacy} Comfy Org موافقت میکنید.",
|
||||
"totalDueToday": "مبلغ قابل پرداخت امروز"
|
||||
},
|
||||
"refreshCredits": "بهروزرسانی اعتبارها",
|
||||
"renewsDate": "تمدید در {date}",
|
||||
"required": {
|
||||
"pollingFailed": "فعالسازی اشتراک ناموفق بود",
|
||||
|
||||
@@ -2190,6 +2190,10 @@
|
||||
"image": {
|
||||
"name": "تصویر"
|
||||
},
|
||||
"keep_aspect": {
|
||||
"name": "حفظ نسبت تصویر",
|
||||
"tooltip": "آیا برش تصویر برای تطبیق با اندازه خروجی کشیده شود یا با پد سیاه نسبت تصویر حفظ گردد."
|
||||
},
|
||||
"output_height": {
|
||||
"name": "ارتفاع خروجی",
|
||||
"tooltip": "ارتفاعی که هر برش به آن تغییر اندازه داده میشود."
|
||||
@@ -2313,6 +2317,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DrawBBoxes": {
|
||||
"display_name": "ترسیم BBoxes",
|
||||
"inputs": {
|
||||
"bboxes": {
|
||||
"name": "bboxes"
|
||||
},
|
||||
"image": {
|
||||
"name": "تصویر"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصویر خروجی",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"DualCFGGuider": {
|
||||
"display_name": "راهنمای DualCFG",
|
||||
"inputs": {
|
||||
@@ -12226,6 +12247,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RTDETR_detect": {
|
||||
"display_name": "شناسایی RT-DETR",
|
||||
"inputs": {
|
||||
"class_name": {
|
||||
"name": "نام کلاس",
|
||||
"tooltip": "فیلتر کردن شناساییها بر اساس کلاس. برای غیرفعال کردن فیلتر، مقدار 'all' را قرار دهید."
|
||||
},
|
||||
"image": {
|
||||
"name": "تصویر"
|
||||
},
|
||||
"max_detections": {
|
||||
"name": "حداکثر شناسایی",
|
||||
"tooltip": "حداکثر تعداد شناسایی برای هر تصویر. به ترتیب امتیاز اطمینان نزولی."
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل"
|
||||
},
|
||||
"threshold": {
|
||||
"name": "آستانه"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "bboxes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RandomCropImages": {
|
||||
"display_name": "برش تصادفی تصاویر",
|
||||
"inputs": {
|
||||
@@ -15631,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "تصویر UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user