Compare commits

...

20 Commits

Author SHA1 Message Date
dante01yoon
3a83180770 fix: skip non-serializable source widgets in promoted view serialization
Add sourceSerialize getter to PromotedWidgetView that checks the
resolved source widget's serialize flag. SubgraphNode.serialize() and
configure now filter out views whose source widget has serialize=false,
preventing preview/audio/video widget values from being persisted.
2026-04-09 13:19:50 +09:00
dante01yoon
c191a88d05 fix: serialize subgraph instance widget values 2026-04-09 13:18:15 +09:00
dante
d4beb73b1c test: cover multi-instance subgraph widget values 2026-04-09 13:18:15 +09:00
dante
04f792452b fix: restore per-instance promoted widget reads 2026-04-09 13:17:32 +09:00
dante01yoon
5de9ee7bea fix: use serializeValue for per-instance execution instead of overriding value getter
The value getter must use the shared widget state store for UI/DOM widget
persistence (e.g. textarea values surviving navigation).  Instead of
overriding the getter, add serializeValue() on PromotedWidgetView — the
execution path (graphToPrompt) already prefers serializeValue over .value.
Also wire serializeValue into ExecutableNodeDTO for subgraph input
resolution.  This cleanly separates the UI read path (shared store) from
the execution read path (per-instance map).
2026-04-09 13:17:32 +09:00
dante01yoon
7accc99402 fix: wire per-instance widget values into value getter and remove dead getInstanceValue
Integrate _instanceWidgetValues lookup into the PromotedWidgetView.value
getter (before the shared widget state store fallback) so multiple
SubgraphNode instances actually return their own configured values.
Remove the unused getInstanceValue() method. Rewrite the test to assert
through the public .value getter instead of inspecting private internals.
2026-04-09 13:17:32 +09:00
dante01yoon
81af70cc93 fix: preserve getter/setter compatibility, use getInstanceValue for execution
Revert getter to original behavior so inner node sync and E2E
navigation tests are unaffected. Per-instance values are stored
via setter and during configure, accessible through
getInstanceValue() for execution contexts.
2026-04-09 13:17:32 +09:00
dante01yoon
073f57c907 fix: use view setter during configure to sync inner node values
Use view.value setter (not direct Map write) when restoring
widgets_values during configure, so the value is stored in the
per-instance map AND synced to the inner node widget for E2E
compatibility.
2026-04-09 13:17:32 +09:00
dante01yoon
b5138cb800 fix: include disambiguatingSourceNodeId in instance widget key
The per-instance key must include disambiguatingSourceNodeId to
avoid collisions when multiple promoted widgets share the same
sourceNodeId and sourceWidgetName (e.g. nested subgraph promotions).
2026-04-09 13:17:32 +09:00
dante01yoon
731967c79d fix: store promoted widget values per SubgraphNode instance
Multiple SubgraphNode instances sharing the same blueprint wrote
promoted widget values to the shared inner node, causing the last
configure to overwrite all previous instances' values.

Add a per-instance Map (_instanceWidgetValues) on SubgraphNode that
stores promoted widget values independently. PromotedWidgetView reads
from this map first, falling back to the widget store and inner node.
During configure, widgets_values are restored into this map after
promoted views are created.
2026-04-09 13:17:32 +09:00
dante01yoon
6974bf626b test: add failing tests for multi-instance subgraph widget value collision
Multiple SubgraphNode instances sharing the same blueprint overwrite
each other's promoted widget values because PromotedWidgetView writes
directly to shared inner node widgets.
2026-04-09 13:17:31 +09:00
Dante
65d1313443 fix: preserve CustomCombo options through clone and paste (#10853)
## Summary

- Fix `CustomCombo` copy/paste so the combo keeps its option list and
selected value
- Scope the fix to `src/extensions/core/customWidgets.ts` instead of
changing LiteGraph core deserialization
- Replace the previous round-trip test with a regression test that
exercises the actual clone/paste lifecycle

- Fixes #9927

## Root Cause

`CustomCombo` option widgets override `value` to read from
`widgetValueStore`.
During `node.clone()` and clipboard paste, `configure()` restores widget
values before the new node is added to the graph and before those
widgets are registered in the store.
That meant the option widgets read back as empty while `updateCombo()`
was rebuilding the combo state, so `comboWidget.options.values` became
blank on the pasted node.

## Fix

Keep a local fallback value for each generated `option*` widget in
`customWidgets.ts`.
The getter now returns the store-backed value when available and falls
back to the locally restored value before store registration.
This preserves the option list during `clone().serialize()` and paste
without hard-coding `CustomCombo` behavior into
`LGraphNode.configure()`.

## Why No E2E Test

This regression happens in the internal LiteGraph clipboard lifecycle:
`clone() -> serialize() -> createNode() -> configure() -> graph.add()`.
The failing state is the transient pre-add relationship between
`CustomCombo`'s store-backed option widgets and
`comboWidget.options.values`, which is not directly exposed through a
stable DOM assertion in the current Playwright suite.
A focused unit regression test is the most direct way to cover that
lifecycle without depending on brittle canvas interaction timing.

## Test Plan

- [x] Regression test covers `clone().serialize() -> createNode() ->
configure() -> graph.add()` for `CustomCombo`
- [ ] CI on the latest two commits (`81ac6d2ce`, `94147caf1`)
- [ ] Manual: create `CustomCombo` -> add `alpha`, `beta`, `gamma` ->
select `beta` -> copy/paste -> verify the pasted combo still shows all
three options and keeps `beta` selected
2026-04-09 12:35:20 +09:00
Alexander Brown
f90d6cf607 test: migrate 132 test files from @vue/test-utils to @testing-library/vue (#10965)
## Summary

Migrate 132 test files from `@vue/test-utils` (VTU) to
`@testing-library/vue` (VTL) with `@testing-library/user-event`,
adopting user-centric behavioral testing patterns across the codebase.

## Changes

- **What**: Systematic migration of component/unit tests from VTU's
`mount`/`wrapper` API to VTL's `render`/`screen`/`userEvent` API across
132 files in `src/`
- **Breaking**: None — test-only changes, no production code affected

### Migration breakdown

| Batch | Files | Description |
|-------|-------|-------------|
| 1 | 19 | Simple render/assert tests |
| 2A | 16 | Interactive tests with user events |
| 2B-1 | 14 | Interactive tests (continued) |
| 2B-2 | 32 | Interactive tests (continued) |
| 3A–3E | 51 | Complex tests (stores, composables, heavy mocking) |
| Lint fix | 7 | `await` on `fireEvent` calls for `no-floating-promises`
|
| Review fixes | 15 | Address CodeRabbit feedback (3 rounds) |

### Review feedback addressed

- Removed class-based assertions (`text-ellipsis`, `pr-3`, `.pi-save`,
`.skeleton`, `.bg-black\/15`, Tailwind utilities) in favor of
behavioral/accessible queries
- Added null guards before `querySelector` casts
- Added `expect(roots).toHaveLength(N)` guards before indexed NodeList
access
- Wrapped fake timer tests in `try/finally` for guaranteed cleanup
- Split double-render tests into focused single-render tests
- Replaced CSS class selectors with
`screen.getByText`/`screen.getByRole` queries
- Updated stubs to use semantic `role`/`aria-label` instead of CSS
classes
- Consolidated redundant edge-case tests
- Removed manual `document.body.appendChild` in favor of VTL container
management
- Used distinct mock return values to verify command wiring

### VTU holdouts (2 files)

These files intentionally retain `@vue/test-utils` because their
components use `<script setup>` without `defineExpose`, making internal
computed properties and methods inaccessible via VTL:

1. **`NodeWidgets.test.ts`** — partial VTU for `vm.processedWidgets`
2. **`WidgetSelectDropdown.test.ts`** — full VTU for heavy
`wrapper.vm.*` access

## Follow-up

Deferred items (`ComponentProps` typing, camelCase listener props)
tracked in #10966.

## Review Focus

- Test correctness: all migrated tests preserve original behavioral
coverage
- VTL idioms: proper use of `screen` queries, `userEvent`, and
accessibility-based selectors
- The 2 VTU holdout files are intentional, not oversights

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10965-test-migrate-132-test-files-from-vue-test-utils-to-testing-library-vue-33c6d73d36508199a6a7e513cf5d8296)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-08 19:21:42 -07:00
Christian Byrne
2c34d955cb feat(website): add zh-CN translations for homepage and secondary pages (#10157)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10157-feat-website-add-zh-CN-translations-for-homepage-and-secondary-pages-3266d73d3650811f918cc35eca62a4bc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 19:18:19 -07:00
Christian Byrne
8b6c1b3649 refactor: consolidate SubscriptionTier type (#10487)
## Summary

Consolidate the `SubscriptionTier` type from 3 independent definitions
into a single source of truth in `tierPricing.ts`.

## Changes

- **What**: Exported `SubscriptionTier` from `tierPricing.ts`. Removed
hand-written unions from `workspaceApi.ts` (lines 80-88),
`PricingTable.vue`, and `PricingTableWorkspace.vue`. All now import from
the canonical location.
- **Files**: 4 files changed (type-only, ~5 net lines)

## Review Focus

- This is a type-only change — `pnpm typecheck` is the primary
validation
- If the OpenAPI schema ever adds tiers, there is now one place to
update

## Stack

PR 5/5: #10483#10484#10485#10486 → **→ This PR**
2026-04-08 19:17:44 -07:00
Christian Byrne
026aeb71b2 refactor: decompose MembersPanelContent into focused components (#10486)
## Summary

Decompose the 562-line `MembersPanelContent.vue` into focused
single-responsibility components.

## Changes

- **What**: Extracted `RoleBadge.vue`, `MemberListItem.vue`,
`PendingInvitesList.vue`, and `MemberUpsellBanner.vue` from
`MembersPanelContent.vue`. Added `RoleBadge.test.ts`. The parent
component is slimmed from 562 → ~120 lines.
- **Files**: 6 files changed (4 new components + 1 new test + 1
refactored)

## Review Focus

- Component boundaries — each extracted component has a clear single
responsibility
- `MembersPanelContent.vue` still orchestrates all behavior; extracted
components are presentational
- Visual QA needed: workspace settings panel should look and behave
identically

## Stack

PR 4/5: #10483#10484#10485 → **→ This PR** → #10487

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-08 18:57:11 -07:00
Alexander Brown
d96a7d2b32 fix: resolve lint/knip warnings and upgrade oxlint, oxfmt, knip (#10973)
## Changes

- Fix unsafe optional chaining warnings in 2 test files
- Promote `no-unsafe-optional-chaining` to error in oxlintrc
- Remove stale knip ignores (useGLSLRenderer, website deps, astro entry)
- Remove `vue/no-dupe-keys` from oxlintrc (removed from oxlint vue
plugin; `eslint/no-dupe-keys` covers it)
- Un-export unused `UniformSource`/`UniformSources` interfaces
- Dedupe pnpm lockfile

## Dependency Upgrades

| Package | Before | After |
|---------|--------|-------|
| knip | 6.0.1 | 6.3.1 |
| oxlint | 1.55.0 | 1.59.0 |
| oxfmt | 0.40.0 | 0.44.0 |
| eslint-plugin-oxlint | 1.55.0 | 1.59.0 |
| oxlint-tsgolint | 0.17.0 | 0.20.0 |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10973-fix-resolve-lint-knip-warnings-and-upgrade-oxlint-oxfmt-knip-33c6d73d36508135a773f0a174471cf9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 18:30:37 -07:00
Comfy Org PR Bot
1720aa0286 1.44.0 (#10974)
Minor version increment to 1.44.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10974-1-44-0-33c6d73d365081d98a3bd646d3374b3b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-08 18:13:31 -07:00
Christian Byrne
c671a33182 fix(ci): resolve pnpm version conflict in version bump workflow (#10972)
## Summary

Removes hardcoded `version: 10` from `pnpm/action-setup` and instead
injects the `packageManager` field into `package.json` when absent
(legacy `core/*` branches).

## Why

PR #10952 re-added `version: 10` to fix old branches lacking
`packageManager`. But `main` now has **both** `version: 10` (workflow)
and `packageManager: pnpm@10.33.0` (`package.json`), causing
`pnpm/action-setup` to error with:

> Multiple versions of pnpm specified

Failed run:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24158869559

This fix handles both cases:
- **`main`**: has `packageManager` → action reads it directly, no
conflict
- **`core/1.42` etc**: missing `packageManager` → step injects it before
the action runs

E2E test not applicable — this is a CI workflow configuration change
with no user-facing behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10972-fix-ci-resolve-pnpm-version-conflict-in-version-bump-workflow-33c6d73d36508112802df75c0dd5ea50)
by [Unito](https://www.unito.io)
2026-04-08 15:13:33 -07:00
Alexander Brown
25d1ac7456 test: reorganize subgraph test suite into composable domain specs (#10759)
## Summary

Reorganize the subgraph test suite so browser tests are thin
representative user journeys while lower-level Vitest suites own
combinatorics, migration edge cases, and data-shape semantics.

## Changes

- **What**: Migrate 17 flat subgraph browser specs into 10
domain-organized specs under `browser_tests/tests/subgraph/`, move
redundant semantic coverage down to 8 Vitest owner suites, delete all
legacy flat files
- **Browser specs** (54 tests): `subgraphSlots`, `subgraphPromotion`,
`subgraphPromotionDom`, `subgraphSerialization`, `subgraphNavigation`,
`subgraphNested`, `subgraphLifecycle`, `subgraphCrud`, `subgraphSearch`,
`subgraphOperations`
- **Vitest owners** (230 tests): `SubgraphNode.test.ts` (rename/label
propagation), `subgraphNodePromotion.test.ts`,
`promotedWidgetView.test.ts`, `SubgraphSerialization.test.ts`
(duplicate-ID remap), `SubgraphWidgetPromotion.test.ts` (legacy
hydration), `subgraphNavigationStore*.test.ts` (viewport cache,
workflow-switch), `subgraphStore.test.ts` (search aliases, description)
- **Net effect**: browser suite shrinks from ~96 scattered tests to 54
focused journeys

## Review Focus

- Coverage ownership split: each browser test has a unique UI-only
failure mode; semantic coverage lives in Vitest
- `subgraphPromotionDom.spec.ts` forces LiteGraph mode and uses
`canvas.openSubgraph()` instead of `navigateIntoSubgraph()` to avoid a
wrapper-specific DOM overlay duplication issue — entry-affordance
coverage lives in `subgraphNavigation.spec.ts`
- No product code changes — test-only migration

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10759-test-reorganize-subgraph-test-suite-into-composable-domain-specs-3336d73d365081b0a56bcbf809b1f584)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 15:04:33 -07:00
210 changed files with 10097 additions and 9174 deletions

View File

@@ -142,10 +142,22 @@ jobs:
fi
echo "✅ Branch '$BRANCH' exists"
- name: Ensure packageManager field exists
run: |
if ! grep -q '"packageManager"' package.json; then
# Old branches (e.g. core/1.42) predate the packageManager field.
# Inject it so pnpm/action-setup can resolve the version.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
pkg.packageManager = 'pnpm@10.33.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Injected packageManager into package.json for legacy branch"
fi
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -64,6 +64,7 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -104,8 +105,7 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
"vue/no-import-compiler-macros": "error"
},
"overrides": [
{

View File

@@ -1,9 +1,15 @@
<script setup lang="ts">
const features = [
{ icon: '📚', label: 'Guided Tutorials' },
{ icon: '🎥', label: 'Video Courses' },
{ icon: '🛠️', label: 'Hands-on Projects' }
]
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const features = computed(() => [
{ icon: '📚', label: t('academy.tutorials', locale) },
{ icon: '🎥', label: t('academy.videos', locale) },
{ icon: '🛠️', label: t('academy.projects', locale) }
])
</script>
<template>
@@ -13,14 +19,15 @@ const features = [
<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
{{ t('academy.badge', locale) }}
</span>
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
<h2 class="mt-6 text-3xl font-bold text-white">
{{ t('academy.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
Learn to build professional AI workflows with guided tutorials, video
courses, and hands-on projects.
{{ t('academy.body', locale) }}
</p>
<!-- Feature bullets -->
@@ -40,7 +47,7 @@ const features = [
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
{{ t('academy.cta', locale) }}
</a>
</div>
</section>

View File

@@ -1,37 +1,43 @@
<script setup lang="ts">
const cards = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards = computed(() => [
{
icon: '🖥️',
title: 'Comfy Desktop',
description: 'Full power on your local machine. Free and open source.',
cta: 'DOWNLOAD',
title: t('cta.desktop.title', locale),
description: t('cta.desktop.desc', locale),
cta: t('cta.desktop.cta', locale),
href: '/download',
outlined: false
},
{
icon: '☁️',
title: 'Comfy Cloud',
description: 'Run workflows in the cloud. No GPU required.',
cta: 'TRY CLOUD',
title: t('cta.cloud.title', locale),
description: t('cta.cloud.desc', locale),
cta: t('cta.cloud.cta', locale),
href: 'https://app.comfy.org',
outlined: false
},
{
icon: '⚡',
title: 'Comfy API',
description: 'Integrate AI generation into your applications.',
cta: 'VIEW DOCS',
title: t('cta.api.title', locale),
description: t('cta.api.desc', locale),
cta: t('cta.api.cta', locale),
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
{{ t('cta.heading', locale) }}
</h2>
<!-- CTA cards -->

View File

@@ -1,30 +1,37 @@
<script setup lang="ts">
const steps = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = computed(() => [
{
number: '1',
title: 'Download & Sign Up',
description: 'Get Comfy Desktop for free or create a Cloud account'
title: t('getStarted.step1.title', locale),
description: t('getStarted.step1.desc', locale)
},
{
number: '2',
title: 'Load a Workflow',
description:
'Choose from thousands of community workflows or build your own'
title: t('getStarted.step2.title', locale),
description: t('getStarted.step2.desc', locale)
},
{
number: '3',
title: 'Generate',
description: 'Hit run and watch your AI workflow come to life'
title: t('getStarted.step3.title', locale),
description: t('getStarted.step3.desc', locale)
}
]
])
</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>
<h2 class="text-3xl font-bold text-white">
{{ t('getStarted.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
From download to your first AI-generated output in three simple steps
{{ t('getStarted.subheading', locale) }}
</p>
<!-- Steps -->
@@ -55,7 +62,7 @@ const steps = [
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
{{ t('getStarted.cta', locale) }}
</a>
</div>
</section>

View File

@@ -1,16 +1,23 @@
<script setup lang="ts">
const ctaButtons = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctaButtons = computed(() => [
{
label: 'GET STARTED',
label: t('hero.cta.getStarted', locale),
href: 'https://app.comfy.org',
variant: 'solid' as const
},
{
label: 'LEARN MORE',
label: t('hero.cta.learnMore', locale),
href: '/about',
variant: 'outline' as const
}
]
])
</script>
<template>
@@ -39,12 +46,11 @@ const ctaButtons = [
<h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
>
Professional Control of Visual AI
{{ t('hero.headline', locale) }}
</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.
{{ t('hero.subheadline', locale) }}
</p>
<div class="mt-8 flex flex-wrap gap-4">

View File

@@ -1,3 +1,10 @@
<script setup lang="ts">
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="bg-black py-24">
<div class="mx-auto max-w-4xl px-6 text-center">
@@ -7,13 +14,11 @@
</span>
<h2 class="text-4xl font-bold text-white md:text-5xl">
Method, Not Magic
{{ t('manifesto.heading', locale) }}
</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.
{{ t('manifesto.body', locale) }}
</p>
<!-- Separator line -->

View File

@@ -1,6 +1,16 @@
<!-- TODO: Replace with actual workflow demo content -->
<script setup lang="ts">
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const features = computed(() => [
t('showcase.nodeEditor', locale),
t('showcase.realTimePreview', locale),
t('showcase.versionControl', locale)
])
</script>
<template>
@@ -8,9 +18,11 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
<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>
<h2 class="text-3xl font-bold text-white">
{{ t('showcase.heading', locale) }}
</h2>
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
Watch how professionals build AI workflows with unprecedented control
{{ t('showcase.subheading', locale) }}
</p>
</div>
@@ -28,7 +40,9 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
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>
<p class="text-sm text-smoke-700">
{{ t('showcase.placeholder', locale) }}
</p>
</div>
</div>

View File

@@ -1,39 +1,73 @@
<script setup lang="ts">
const columns = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { localePath, t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const columns = computed(() => [
{
title: 'Product',
title: t('footer.product', locale),
links: [
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
{
label: t('footer.comfyDesktop', locale),
href: localePath('/download', locale)
},
{ label: t('footer.comfyCloud', locale), href: 'https://app.comfy.org' },
{ label: t('footer.comfyHub', locale), href: 'https://hub.comfy.org' },
{
label: t('footer.pricing', locale),
href: localePath('/pricing', locale)
}
]
},
{
title: 'Resources',
title: t('footer.resources', locale),
links: [
{ label: 'Documentation', href: 'https://docs.comfy.org' },
{ label: 'Blog', href: 'https://blog.comfy.org' },
{ label: 'Gallery', href: '/gallery' },
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
{
label: t('footer.documentation', locale),
href: 'https://docs.comfy.org'
},
{ label: t('footer.blog', locale), href: 'https://blog.comfy.org' },
{
label: t('footer.gallery', locale),
href: localePath('/gallery', locale)
},
{
label: t('footer.github', locale),
href: 'https://github.com/comfyanonymous/ComfyUI'
}
]
},
{
title: 'Company',
title: t('footer.company', locale),
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
{ label: t('footer.about', locale), href: localePath('/about', locale) },
{
label: t('footer.careers', locale),
href: localePath('/careers', locale)
},
{
label: t('footer.enterprise', locale),
href: localePath('/enterprise', locale)
}
]
},
{
title: 'Legal',
title: t('footer.legal', locale),
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
{
label: t('footer.terms', locale),
href: localePath('/terms-of-service', locale)
},
{
label: t('footer.privacy', locale),
href: localePath('/privacy-policy', locale)
}
]
}
]
])
const socials = [
{
@@ -76,11 +110,16 @@ const socials = [
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<p class="mt-4 text-sm text-smoke-700">
Professional control of visual AI.
{{ t('footer.tagline', locale) }}
</p>
</div>
@@ -113,7 +152,8 @@ const socials = [
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
&copy; {{ new Date().getFullYear() }}
{{ t('footer.copyright', locale) }}
</p>
<!-- Social icons -->

View File

@@ -1,15 +1,23 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { localePath, t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
const navLinks = computed(() => [
{
label: t('nav.enterprise', locale),
href: localePath('/enterprise', locale)
},
{ label: t('nav.gallery', locale), href: localePath('/gallery', locale) },
{ label: t('nav.about', locale), href: localePath('/about', locale) },
{ label: t('nav.careers', locale), href: localePath('/careers', locale) }
])
const ctaLinks = [
{
@@ -49,14 +57,19 @@ onUnmounted(() => {
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
:aria-label="t('nav.ariaLabel', locale)"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
@@ -77,8 +90,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
@@ -90,7 +103,7 @@ onUnmounted(() => {
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
:aria-label="t('nav.toggleMenu', locale)"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
@@ -135,8 +148,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>

View File

@@ -1,4 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logos = [
'Harman',
'Tencent',
@@ -14,11 +20,11 @@ const logos = [
'EA'
]
const metrics = [
{ value: '60K+', label: 'Custom Nodes' },
{ value: '106K+', label: 'GitHub Stars' },
{ value: '500K+', label: 'Community Members' }
]
const metrics = computed(() => [
{ value: '60K+', label: t('social.customNodes', locale) },
{ value: '106K+', label: t('social.githubStars', locale) },
{ value: '500K+', label: t('social.communityMembers', locale) }
])
</script>
<template>
@@ -28,7 +34,7 @@ const metrics = [
<p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
>
Trusted by Industry Leaders
{{ t('social.heading', locale) }}
</p>
<!-- Logo row -->

View File

@@ -1,9 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const activeFilter = ref('All')
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const industryKeys = [
'All',
'VFX',
'Gaming',
'Advertising',
'Photography'
] as const
const industryLabels = computed(() => ({
All: t('testimonials.all', locale),
VFX: t('testimonials.vfx', locale),
Gaming: t('testimonials.gaming', locale),
Advertising: t('testimonials.advertising', locale),
Photography: t('testimonials.photography', locale)
}))
const activeFilter = ref<(typeof industryKeys)[number]>('All')
const testimonials = [
{
@@ -12,7 +31,7 @@ const testimonials = [
name: 'Sarah Chen',
title: 'Lead Technical Artist',
company: 'Studio Alpha',
industry: 'VFX'
industry: 'VFX' as const
},
{
quote:
@@ -20,7 +39,7 @@ const testimonials = [
name: 'Marcus Rivera',
title: 'Creative Director',
company: 'PixelForge',
industry: 'Gaming'
industry: 'Gaming' as const
},
{
quote:
@@ -28,7 +47,7 @@ const testimonials = [
name: 'Yuki Tanaka',
title: 'Head of AI',
company: 'CreativeX',
industry: 'Advertising'
industry: 'Advertising' as const
}
]
@@ -42,13 +61,13 @@ const filteredTestimonials = computed(() => {
<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
{{ t('testimonials.heading', locale) }}
</h2>
<!-- Industry filter pills -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
v-for="industry in industries"
v-for="industry in industryKeys"
:key="industry"
type="button"
:aria-pressed="activeFilter === industry"
@@ -60,7 +79,7 @@ const filteredTestimonials = computed(() => {
"
@click="activeFilter = industry"
>
{{ industry }}
{{ industryLabels[industry] }}
</button>
</div>
@@ -85,7 +104,7 @@ const filteredTestimonials = computed(() => {
<span
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
>
{{ testimonial.industry }}
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
</span>
</article>
</div>

View File

@@ -1,14 +1,18 @@
<!-- TODO: Wire category content swap when final assets arrive -->
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const categories = [
'VFX & Animation',
'Creative Agencies',
'Gaming',
'eCommerce & Fashion',
'Community & Hobbyists'
]
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const categories = computed(() => [
t('useCase.vfx', locale),
t('useCase.agencies', locale),
t('useCase.gaming', locale),
t('useCase.ecommerce', locale),
t('useCase.community', locale)
])
const activeCategory = ref(0)
</script>
@@ -27,7 +31,7 @@ const activeCategory = ref(0)
<!-- 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
{{ t('useCase.heading', locale) }}
</h2>
<nav
@@ -52,15 +56,14 @@ const activeCategory = ref(0)
</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.
{{ t('useCase.body', locale) }}
</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
{{ t('useCase.cta', locale) }}
</a>
</div>

View File

@@ -1,34 +1,37 @@
<script setup lang="ts">
const pillars = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const pillars = computed(() => [
{
icon: '⚡',
title: 'Build',
description:
'Design complex AI workflows visually with our node-based editor'
title: t('pillars.buildTitle', locale),
description: t('pillars.buildDesc', locale)
},
{
icon: '🎨',
title: 'Customize',
description: 'Fine-tune every parameter across any model architecture'
title: t('pillars.customizeTitle', locale),
description: t('pillars.customizeDesc', locale)
},
{
icon: '🔧',
title: 'Refine',
description:
'Iterate on outputs with precision controls and real-time preview'
title: t('pillars.refineTitle', locale),
description: t('pillars.refineDesc', locale)
},
{
icon: '⚙️',
title: 'Automate',
description:
'Scale your workflows with batch processing and API integration'
title: t('pillars.automateTitle', locale),
description: t('pillars.automateDesc', locale)
},
{
icon: '🚀',
title: 'Run',
description: 'Deploy locally or in the cloud with identical results'
title: t('pillars.runTitle', locale),
description: t('pillars.runDesc', locale)
}
]
])
</script>
<template>
@@ -36,10 +39,10 @@ const pillars = [
<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
{{ t('pillars.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
Five powerful capabilities that give you complete control
{{ t('pillars.subheading', locale) }}
</p>
</header>

View File

@@ -0,0 +1,253 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// HeroSection
'hero.headline': {
en: 'Professional Control of Visual AI',
'zh-CN': '视觉 AI 的专业控制'
},
'hero.subheadline': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
},
'hero.cta.getStarted': { en: 'GET STARTED', 'zh-CN': '立即开始' },
'hero.cta.learnMore': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
// SocialProofBar
'social.heading': {
en: 'Trusted by Industry Leaders',
'zh-CN': '受到行业领导者的信赖'
},
'social.customNodes': { en: 'Custom Nodes', 'zh-CN': '自定义节点' },
'social.githubStars': { en: 'GitHub Stars', 'zh-CN': 'GitHub 星标' },
'social.communityMembers': {
en: 'Community Members',
'zh-CN': '社区成员'
},
// ProductShowcase
'showcase.heading': { en: 'See Comfy in Action', 'zh-CN': '观看 Comfy 实战' },
'showcase.subheading': {
en: 'Watch how professionals build AI workflows with unprecedented control',
'zh-CN': '观看专业人士如何以前所未有的控制力构建 AI 工作流'
},
'showcase.placeholder': {
en: 'Workflow Demo Coming Soon',
'zh-CN': '工作流演示即将推出'
},
'showcase.nodeEditor': { en: 'Node-Based Editor', 'zh-CN': '节点编辑器' },
'showcase.realTimePreview': {
en: 'Real-Time Preview',
'zh-CN': '实时预览'
},
'showcase.versionControl': {
en: 'Version Control',
'zh-CN': '版本控制'
},
// ValuePillars
'pillars.heading': {
en: 'The Building Blocks of AI Production',
'zh-CN': 'AI 制作的基本要素'
},
'pillars.subheading': {
en: 'Five powerful capabilities that give you complete control',
'zh-CN': '五大强大功能,让您完全掌控'
},
'pillars.buildTitle': { en: 'Build', 'zh-CN': '构建' },
'pillars.buildDesc': {
en: 'Design complex AI workflows visually with our node-based editor',
'zh-CN': '使用节点编辑器直观地设计复杂的 AI 工作流'
},
'pillars.customizeTitle': { en: 'Customize', 'zh-CN': '自定义' },
'pillars.customizeDesc': {
en: 'Fine-tune every parameter across any model architecture',
'zh-CN': '在任何模型架构中微调每个参数'
},
'pillars.refineTitle': { en: 'Refine', 'zh-CN': '优化' },
'pillars.refineDesc': {
en: 'Iterate on outputs with precision controls and real-time preview',
'zh-CN': '通过精确控制和实时预览迭代输出'
},
'pillars.automateTitle': { en: 'Automate', 'zh-CN': '自动化' },
'pillars.automateDesc': {
en: 'Scale your workflows with batch processing and API integration',
'zh-CN': '通过批处理和 API 集成扩展工作流'
},
'pillars.runTitle': { en: 'Run', 'zh-CN': '运行' },
'pillars.runDesc': {
en: 'Deploy locally or in the cloud with identical results',
'zh-CN': '在本地或云端部署,获得相同的结果'
},
// UseCaseSection
'useCase.heading': {
en: 'Built for Every Creative Industry',
'zh-CN': '为每个创意行业而生'
},
'useCase.vfx': { en: 'VFX & Animation', 'zh-CN': '视觉特效与动画' },
'useCase.agencies': { en: 'Creative Agencies', 'zh-CN': '创意机构' },
'useCase.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
'useCase.ecommerce': {
en: 'eCommerce & Fashion',
'zh-CN': '电商与时尚'
},
'useCase.community': {
en: 'Community & Hobbyists',
'zh-CN': '社区与爱好者'
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows, and a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流和一个比任何公司都更快构建的社区驱动。'
},
'useCase.cta': { en: 'EXPLORE WORKFLOWS', 'zh-CN': '探索工作流' },
// CaseStudySpotlight
'caseStudy.heading': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'caseStudy.subheading': {
en: 'See how leading studios use Comfy in production',
'zh-CN': '了解领先工作室如何在生产中使用 Comfy'
},
'caseStudy.readMore': { en: 'READ CASE STUDY', 'zh-CN': '阅读案例' },
// TestimonialsSection
'testimonials.heading': {
en: 'What Professionals Say',
'zh-CN': '专业人士的评价'
},
'testimonials.all': { en: 'All', 'zh-CN': '全部' },
'testimonials.vfx': { en: 'VFX', 'zh-CN': '特效' },
'testimonials.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
'testimonials.advertising': { en: 'Advertising', 'zh-CN': '广告' },
'testimonials.photography': { en: 'Photography', 'zh-CN': '摄影' },
// GetStartedSection
'getStarted.heading': {
en: 'Get Started in Minutes',
'zh-CN': '几分钟即可开始'
},
'getStarted.subheading': {
en: 'From download to your first AI-generated output in three simple steps',
'zh-CN': '从下载到首次 AI 生成输出,只需三个简单步骤'
},
'getStarted.step1.title': {
en: 'Download & Sign Up',
'zh-CN': '下载与注册'
},
'getStarted.step1.desc': {
en: 'Get Comfy Desktop for free or create a Cloud account',
'zh-CN': '免费获取 Comfy Desktop 或创建云端账号'
},
'getStarted.step2.title': {
en: 'Load a Workflow',
'zh-CN': '加载工作流'
},
'getStarted.step2.desc': {
en: 'Choose from thousands of community workflows or build your own',
'zh-CN': '从数千个社区工作流中选择,或自行构建'
},
'getStarted.step3.title': { en: 'Generate', 'zh-CN': '生成' },
'getStarted.step3.desc': {
en: 'Hit run and watch your AI workflow come to life',
'zh-CN': '点击运行,观看 AI 工作流生动呈现'
},
'getStarted.cta': { en: 'DOWNLOAD COMFY', 'zh-CN': '下载 COMFY' },
// CTASection
'cta.heading': {
en: 'Choose Your Way to Comfy',
'zh-CN': '选择您的 Comfy 方式'
},
'cta.desktop.title': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
'cta.desktop.desc': {
en: 'Full power on your local machine. Free and open source.',
'zh-CN': '在本地机器上释放全部性能。免费开源。'
},
'cta.desktop.cta': { en: 'DOWNLOAD', 'zh-CN': '下载' },
'cta.cloud.title': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'cta.cloud.desc': {
en: 'Run workflows in the cloud. No GPU required.',
'zh-CN': '在云端运行工作流,无需 GPU。'
},
'cta.cloud.cta': { en: 'TRY CLOUD', 'zh-CN': '试用云端' },
'cta.api.title': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
'cta.api.desc': {
en: 'Integrate AI generation into your applications.',
'zh-CN': '将 AI 生成功能集成到您的应用程序中。'
},
'cta.api.cta': { en: 'VIEW DOCS', 'zh-CN': '查看文档' },
// ManifestoSection
'manifesto.heading': { en: 'Method, Not Magic', 'zh-CN': '方法,而非魔法' },
'manifesto.body': {
en: '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.',
'zh-CN':
'我们相信应赋予创作者对 AI 的真正控制权。没有黑箱,没有魔法按钮,而是对创作过程每一步的透明、可复现、逐节点控制。'
},
// AcademySection
'academy.badge': { en: 'COMFY ACADEMY', 'zh-CN': 'COMFY 学院' },
'academy.heading': {
en: 'Master AI Workflows',
'zh-CN': '掌握 AI 工作流'
},
'academy.body': {
en: 'Learn to build professional AI workflows with guided tutorials, video courses, and hands-on projects.',
'zh-CN': '通过指导教程、视频课程和实践项目,学习构建专业的 AI 工作流。'
},
'academy.tutorials': { en: 'Guided Tutorials', 'zh-CN': '指导教程' },
'academy.videos': { en: 'Video Courses', 'zh-CN': '视频课程' },
'academy.projects': { en: 'Hands-on Projects', 'zh-CN': '实践项目' },
'academy.cta': { en: 'EXPLORE ACADEMY', 'zh-CN': '探索学院' },
// SiteNav
'nav.ariaLabel': { en: 'Main navigation', 'zh-CN': '主导航' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.enterprise': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'nav.gallery': { en: 'GALLERY', 'zh-CN': '画廊' },
'nav.about': { en: 'ABOUT', 'zh-CN': '关于' },
'nav.careers': { en: 'CAREERS', 'zh-CN': '招聘' },
'nav.cloud': { en: 'COMFY CLOUD', 'zh-CN': 'COMFY 云端' },
'nav.hub': { en: 'COMFY HUB', 'zh-CN': 'COMFY HUB' },
// SiteFooter
'footer.tagline': {
en: 'Professional control of visual AI.',
'zh-CN': '视觉 AI 的专业控制。'
},
'footer.product': { en: 'Product', 'zh-CN': '产品' },
'footer.resources': { en: 'Resources', 'zh-CN': '资源' },
'footer.company': { en: 'Company', 'zh-CN': '公司' },
'footer.legal': { en: 'Legal', 'zh-CN': '法律' },
'footer.copyright': {
en: 'Comfy Org. All rights reserved.',
'zh-CN': 'Comfy Org. 保留所有权利。'
},
'footer.comfyDesktop': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
'footer.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'footer.comfyHub': { en: 'ComfyHub', 'zh-CN': 'ComfyHub' },
'footer.pricing': { en: 'Pricing', 'zh-CN': '价格' },
'footer.documentation': { en: 'Documentation', 'zh-CN': '文档' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'footer.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'footer.about': { en: 'About', 'zh-CN': '关于' },
'footer.careers': { en: 'Careers', 'zh-CN': '招聘' },
'footer.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
'footer.terms': { en: 'Terms of Service', 'zh-CN': '服务条款' },
'footer.privacy': { en: 'Privacy Policy', 'zh-CN': '隐私政策' }
} as const satisfies Record<string, Record<Locale, string>>
type TranslationKey = keyof typeof translations
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
export function localePath(path: string, locale: Locale): string {
return locale === 'en' ? path : `/${locale}${path}`
}
export type { Locale }

View File

@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
const team = [
{ name: 'comfyanonymous', role: 'Creator of ComfyUI, cofounder' },
{ name: 'Dr.Lt.Data', role: 'Creator of ComfyUI-Manager and Impact/Inspire Pack' },
{ name: 'pythongosssss', role: 'Major contributor, creator of ComfyUI-Custom-Scripts' },
{ name: 'yoland68', role: 'Creator of ComfyCLI, cofounder, ex-Google' },
{ name: 'robinjhuang', role: 'Maintains Comfy Registry, cofounder, ex-Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI event series host, community & partnerships' },
{ name: 'christian-byrne', role: 'Fullstack developer' },
{ name: 'Kosinkadink', role: 'Creator of AnimateDiff-Evolved and Advanced-ControlNet' },
{ name: 'webfiltered', role: 'Overhauled Litegraph library' },
{ name: 'Pablo', role: 'Product Design, ex-AI startup founder' },
{ name: 'ComfyUI Wiki (Daxiong)', role: 'Official docs and templates' },
{ name: 'ctrlbenlu (Ben)', role: 'Software engineer, ex-robotics' },
{ name: 'Purz Beats', role: 'Motion graphics designer and ML Engineer' },
{ name: 'Ricyu (Rich)', role: 'Software engineer, ex-Meta' },
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager Impact/Inspire Pack 作者' },
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
{ name: 'christian-byrne', role: '全栈开发工程师' },
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved Advanced-ControlNet 作者' },
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
]
const collaborators = [
{ name: 'Yogo', role: 'Collaborator' },
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
{ name: 'Julien (MJM)', role: 'Collaborator' },
{ name: 'Yogo', role: '协作者' },
{ name: 'Fill (Machine Delusions)', role: '协作者' },
{ name: 'Julien (MJM)', role: '协作者' },
]
const projects = [
{ name: 'ComfyUI', description: 'The core node-based interface for generative AI workflows.' },
{ name: 'ComfyUI Manager', description: 'Install, update, and manage custom nodes with one click.' },
{ name: 'Comfy Registry', description: 'The official registry for publishing and discovering custom nodes.' },
{ name: 'Frontends', description: 'The desktop and web frontends that power the ComfyUI experience.' },
{ name: 'Docs', description: 'Official documentation, guides, and tutorials.' },
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
{ name: 'Docs', description: '官方文档、指南和教程。' },
]
const faqs = [
{
q: 'Is ComfyUI free?',
a: 'Yes. ComfyUI is free and open-source under the GPL-3.0 license. You can use it for personal and commercial projects.',
q: 'ComfyUI 免费吗?',
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
},
{
q: 'Who is behind ComfyUI?',
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
q: '谁在开发 ComfyUI',
a: 'ComfyUI comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
},
{
q: 'How can I contribute?',
a: 'Check out our GitHub repositories to report issues, submit pull requests, or build custom nodes. Join our Discord community to connect with other contributors.',
q: '如何参与贡献?',
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
},
{
q: 'What are the future plans?',
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
q: '未来有什么计划?',
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
},
]
---
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
<SiteNav client:load />
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<SiteNav locale="zh-CN" client:load />
<main>
<!-- Hero -->
<!-- 主页横幅 -->
<section class="px-6 pb-24 pt-40 text-center">
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
Crafting the next frontier of visual and audio media
开创视觉与音频媒体的下一个前沿
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
An open-source community and company building the most powerful tools for generative AI creators.
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
</p>
</section>
<!-- Our Mission -->
<!-- 我们的使命 -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
We want to build the operating system for Gen AI.
我们想打造生成式 AI 的操作系统。
</p>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
We're building the foundational tools that give creators full control over generative AI.
From image and video synthesis to audio generation, ComfyUI provides a modular,
node-based environment where professionals and enthusiasts can craft, iterate,
and deploy production-quality workflows — without black boxes.
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
从图像和视频合成到音频生成ComfyUI 提供了一个模块化的
节点式环境,让专业人士和爱好者可以创建、迭代
和部署生产级工作流——没有黑箱。
</p>
</div>
</section>
<!-- What Do We Do? -->
<!-- 我们做什么? -->
<section class="px-6 py-24">
<div class="mx-auto max-w-5xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">What Do We Do?</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
@@ -98,24 +98,23 @@ const faqs = [
</div>
</section>
<!-- Who We Are -->
<!-- 我们是谁 -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold md:text-4xl">Who We Are</h2>
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
ComfyUI started as a personal project by comfyanonymous and grew into a global community
of creators, developers, and researchers. Today, Comfy Org is a small, flat team based in
San Francisco, backed by investors who believe in open-source AI tooling. We work
alongside an incredible community of contributors who build custom nodes, share workflows,
and push the boundaries of what's possible with generative AI.
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
创作者、开发者和研究者社区。今天Comfy Org 是一个位于旧金山的小型扁平化团队,
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
</p>
</div>
</section>
<!-- Team -->
<!-- 团队 -->
<section class="px-6 py-24">
<div class="mx-auto max-w-6xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">Team</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{team.map((member) => (
<div class="rounded-xl border border-white/10 p-5 text-center">
@@ -128,10 +127,10 @@ const faqs = [
</div>
</section>
<!-- Collaborators -->
<!-- 协作者 -->
<section class="bg-charcoal-800 px-6 py-16">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-2xl font-bold">Collaborators</h2>
<h2 class="text-2xl font-bold">协作者</h2>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
@@ -143,10 +142,10 @@ const faqs = [
</div>
</section>
<!-- FAQs -->
<!-- 常见问题 -->
<section class="px-6 py-24">
<div class="mx-auto max-w-3xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">FAQs</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
@@ -158,19 +157,19 @@ const faqs = [
</div>
</section>
<!-- Join Our Team CTA -->
<!-- 加入我们 CTA -->
<section class="bg-charcoal-800 px-6 py-24 text-center">
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
</p>
<a
href="/careers"
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"
>
View Open Positions
查看开放职位
</a>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -78,7 +78,7 @@ const questions = [
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40">
@@ -196,5 +196,5 @@ const questions = [
</div>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -32,7 +32,7 @@ const cards = [
---
<BaseLayout title="下载 — Comfy">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
下载 ComfyUI
@@ -76,5 +76,5 @@ const cards = [
</p>
</div>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="作品集 — Comfy">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="bg-black text-white">
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
@@ -39,5 +39,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</a>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main>
<HeroSection />
<SocialProofBar />
<ProductShowcase />
<ValuePillars />
<UseCaseSection client:visible />
<CaseStudySpotlight />
<TestimonialsSection client:visible />
<GetStartedSection />
<CTASection />
<ManifestoSection />
<AcademySection />
<HeroSection locale="zh-CN" />
<SocialProofBar locale="zh-CN" />
<ProductShowcase locale="zh-CN" />
<ValuePillars locale="zh-CN" />
<UseCaseSection locale="zh-CN" client:visible />
<CaseStudySpotlight locale="zh-CN" />
<TestimonialsSection locale="zh-CN" client:visible />
<GetStartedSection locale="zh-CN" />
<CTASection locale="zh-CN" />
<ManifestoSection locale="zh-CN" />
<AcademySection locale="zh-CN" />
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-3xl px-6 py-24">
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
<p class="mt-2 text-sm text-smoke-500">生效日期2025年4月18日</p>
@@ -229,5 +229,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</p>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
<header class="mb-16">
<h1 class="text-3xl font-bold text-white">服务条款</h1>
@@ -216,5 +216,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</div>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

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

View File

@@ -1,15 +1,8 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (
Array.isArray(entry) &&
entry.length === 2 &&
@@ -18,9 +11,7 @@ export function isPromotedWidgetEntry(
)
}
export function normalizePromotedWidgets(
value: unknown
): PromotedWidgetEntry[] {
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
}
@@ -37,28 +28,6 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -75,7 +44,7 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
@@ -87,14 +56,6 @@ export async function getPseudoPreviewWidgets(
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -1,19 +1,34 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/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)'
)
})
async function duplicateSubgraphNodeViaAltDrag(
comfyPage: ComfyPage
): Promise<void> {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const subgraphPos = await subgraphNode.getPosition()
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
}
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
@@ -37,18 +52,14 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.link != null
(input) => input.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)
})
})
@@ -62,14 +73,15 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
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)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(1)
await expect.poll(() => comfyPage.subgraph.getNodeCount()).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
@@ -82,69 +94,47 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
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)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount - 1)
await expect.poll(() => deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
test.describe('Subgraph Copy', () => {
test('Can duplicate a subgraph node by alt-dragging', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
// 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)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
test('Alt-dragging a subgraph node creates a new subgraph type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
// 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)

View File

@@ -2,209 +2,91 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '@e2e/helpers/promotedWidgets'
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
await comfyPage.subgraph.exitViaBreadcrumb()
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
}
)
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('5')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Removing the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
})

View File

@@ -3,15 +3,8 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/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
@@ -39,15 +32,7 @@ function hasVisibleNodeInViewport() {
}
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.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
@@ -62,18 +47,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
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 breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
@@ -92,59 +71,40 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(breadcrumb).toBeVisible()
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 ({
test('Switching workflows while inside subgraph returns to root graph context and hides the breadcrumb', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => 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()
await expect(backButton).toHaveCount(0)
})
})
@@ -157,7 +117,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
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',
@@ -182,28 +141,26 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
}
])
// 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)
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
// Verify we're in a subgraph
await expect.poll(() => 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')
}
expect(
await comfyPage.subgraph.isInSubgraph(),
'Escape should stay inside the subgraph after the default binding is unset'
).toBe(true)
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
@@ -217,39 +174,36 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
expect(
await comfyPage.subgraph.isInSubgraph(),
'Precondition failed: expected to be inside the subgraph before opening settings'
).toBe(true)
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
// 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()
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeHidden()
// Should still be in subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
test.describe('Viewport', () => {
test.describe('Subgraph viewport restoration', () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
@@ -258,24 +212,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
)
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)
})
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
timeout: 2_000
})
.toBe(true)
})
@@ -293,7 +235,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
timeout: 2_000
})
.toBe(true)
})
@@ -324,7 +266,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
@@ -336,61 +278,43 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
})
test.describe('Progress State', () => {
test.describe('Subgraph progress clear on navigation', () => {
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
await expect.poll(() => 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 })
await expect
.poll(
() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId),
{ timeout: 2_000 }
)
.toBeUndefined()
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
@@ -418,21 +342,23 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
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 }
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(node) =>
typeof node.isSubgraphNode === 'function' &&
node.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 })
return { exists: true, progress: subgraphNode.progress }
}),
{ timeout: 5_000 }
)
.toEqual({ exists: true, progress: undefined })
})
})
})

View File

@@ -4,177 +4,44 @@ import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
test.describe('Nested Subgraphs', { 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 ({
test('Loads and queues without nested promotion resolution failures', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
expect(warnings).toEqual([])
})
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
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)
}
const response = await responsePromise
expect(warnings).toEqual([])
expect(response.ok()).toBe(true)
} finally {
dispose()
}
})
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
}) => {
@@ -210,60 +77,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
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'] },
@@ -284,7 +100,6 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
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()
@@ -310,10 +125,8 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
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()
@@ -345,87 +158,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
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'] },
@@ -447,12 +182,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
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()
})

View File

@@ -2,19 +2,19 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/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.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('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
test.describe('Subgraph Clipboard Operations', () => {
test('Can copy and paste nodes inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
@@ -22,45 +22,43 @@ test.describe(
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodeId = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
})
expect(nodeId).not.toBeNull()
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
String(nodesInSubgraph)
)
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount + 1)
})
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
test.describe('Subgraph History Operations', () => {
test('Can undo and redo operations inside a 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()
@@ -68,7 +66,6 @@ test.describe(
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
@@ -76,5 +73,5 @@ test.describe(
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount)
})
}
)
})
})

View File

@@ -47,25 +47,20 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
// SubgraphNode should have widgets (promoted views)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
test('Preview-capable nodes keep regular and pseudo-widget promotions when converted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
@@ -113,29 +108,12 @@ test.describe(
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
@@ -143,7 +121,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -151,57 +129,26 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Value changes on promoted widget sync to interior widget', async ({
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -211,87 +158,30 @@ test.describe(
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
})
@@ -300,7 +190,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote a widget from inside a subgraph', async ({
test('Can promote and un-promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -308,10 +198,9 @@ test.describe(
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler.click('title')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
@@ -360,12 +249,13 @@ test.describe(
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
@@ -373,12 +263,11 @@ test.describe(
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const ksampler2 = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler2.click('title')
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
@@ -396,10 +285,8 @@ test.describe(
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
@@ -422,26 +309,16 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// Navigate into the subgraph (node id 11)
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// The interior CLIPTextEncode node (id 10) should render a textarea
// widget in Vue mode. Right-click it to verify the contextmenu
// event propagates correctly (fix from PR #9840) and shows the
// ComfyUI context menu with "Promote Widget".
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
// Select the node first so the context menu builds correctly
await comfyPage.vueNodes.selectNode('10')
await comfyPage.nextFrame()
// Dispatch a contextmenu event directly on the textarea. A normal
// right-click is intercepted by the z-999 canvas overlay, but the
// Vue WidgetTextarea.vue handler listens on @contextmenu.capture,
// so dispatching the event directly tests the fix from PR #9840.
const textarea = clipNode.locator('textarea')
await expect(textarea).toBeVisible()
await textarea.dispatchEvent('contextmenu', {
@@ -451,8 +328,6 @@ test.describe(
})
await comfyPage.nextFrame()
// The PrimeVue context menu should show "Promote Widget" since
// the node is inside a subgraph (not the root graph).
const promoteEntry = comfyPage.page
.locator('.p-contextmenu')
.locator('text=Promote Widget')
@@ -462,7 +337,7 @@ test.describe(
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
test('Promoted preview nodes render custom content in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -506,11 +381,6 @@ test.describe(
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
@@ -519,14 +389,35 @@ test.describe(
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// The node body should exist
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('filename_prefix')
expect(promotedNames.some((name) => name.startsWith('$$'))).toBe(true)
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
const loadImagePosition = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: loadImagePosition
})
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
await expect(
nodeBody.locator('.lg-node-widgets > div').first()
).toBeVisible()
await expect(nodeBody.locator('.image-preview img')).toHaveCount(1, {
timeout: 30_000
})
await expect(nodeBody.locator('.lg-node-widgets')).not.toContainText(
'$$canvas-image-preview'
)
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -534,10 +425,6 @@ test.describe(
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
@@ -553,29 +440,12 @@ test.describe(
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
@@ -588,8 +458,11 @@ test.describe(
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
}
expect(editedTextarea).toBe(true)
})
})

View File

@@ -1,218 +1,170 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
// Constants
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
domWidget: '.comfy-multiline-input'
} as const
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
await comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.rootGraph.nodes.find(
(candidate) => String(candidate.id) === targetNodeId
)
if (!node || !('subgraph' in node) || !node.subgraph) {
throw new Error(`Subgraph node ${targetNodeId} not found`)
}
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)'
window.app!.canvas.openSubgraph(node.subgraph, node)
}, nodeId)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
}),
{ timeout: 5_000 }
)
.toBe(true)
}
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await openSubgraphById(comfyPage, '11')
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
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()
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
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)
})
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
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'
)
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
await subgraphNode.delete()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
await openSubgraphById(comfyPage, '11')
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')
await comfyPage.subgraph.removeSlot('input', 'text')
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.subgraph.exitViaBreadcrumb()
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)
})
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
test.describe('DOM Positioning', () => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const parentCount = await comfyPage.page
.locator(VISIBLE_DOM_WIDGET_SELECTOR)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNodeId = String(subgraphNode.id)
await expect(async () => {
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
}).toPass({ timeout: 5000 })
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
await openSubgraphById(comfyPage, '11')
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator
.getByLabel('seed', { exact: true })
.first()
await expect(seedWidget).toBeVisible()
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
})
}
)
})
})

View File

@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
await comfyPage.toast.closeToasts(1)
}
@@ -36,7 +36,7 @@ async function searchAndExpectResult(
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.input.fill(searchTerm)
await expect(comfyPage.searchBox.findResult(expectedResult)).toBeVisible({
timeout: 10000
timeout: 10_000
})
}
@@ -49,35 +49,22 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
)
})
test('Can set search aliases on subgraph and find via search', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', {
aliases: 'qwerty,unicorn'
})
const blueprintName = `test-aliases-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
await searchAndExpectResult(comfyPage, 'unicorn', blueprintName)
})
test('Can set description on subgraph', async ({ comfyPage }) => {
await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetDescription', {
description: 'This is a test description'
})
// Verify the description was set on the subgraph's extra
const description = await comfyPage.page.evaluate(() => {
const subgraph = window['app']!.canvas.subgraph
const subgraph = window.app!.canvas.subgraph
return (subgraph?.extra as Record<string, unknown>)?.BlueprintDescription
})
expect(description).toBe('This is a test description')
})
test('Search aliases persist after publish and reload', async ({
test('Published search aliases remain searchable after reload', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
@@ -89,10 +76,9 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
const blueprintName = `test-persist-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
// Reload the page to ensure aliases are persisted
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].extensionManager
() => window.app && window.app.extensionManager
)
await comfyPage.nextFrame()

View File

@@ -1,433 +1,143 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import {
getPromotedWidgets,
getPromotedWidgetNames,
getPromotedWidgetCount
} from '@e2e/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)
}
}
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
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()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => 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()
const getPromotedHostWidgetValues = async (
comfyPage: ComfyPage,
nodeIds: string[]
) => {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test('Promoted widget value is editable as a text input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
})
}
)
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
await comfyPage.subgraph.serializeAndReload()
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.some(([, widgetName]) => widgetName === 'batch_size')).toBe(
true
)
})
test('Duplicate ID remap workflow remains navigable after a full reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
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)
})
test.describe('Legacy prefixed proxyWidget normalization', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const workflowName =
'subgraphs/subgraph-multi-instance-promoted-text-values'
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})
})

View File

@@ -5,20 +5,18 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import {
expectSlotsWithinBounds,
measureNodeSlotOffsets
} from '@e2e/fixtures/utils/slotBoundsUtil'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const RENAMED_NAME = 'renamed_slot_name'
const RENAMED_SLOT_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
@@ -32,7 +30,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
)
})
test.describe('I/O Slot CRUD', () => {
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -83,8 +81,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -103,8 +99,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -112,10 +106,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount - 1)
})
})
test.describe('Slot Rename', () => {
test('Can rename I/O slots via right-click context menu', async ({
test('Can rename an input slot from the context menu', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -129,13 +121,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
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()
@@ -239,26 +228,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
// 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)) {
if (!graph || !('inputNode' in graph))
throw new Error('Expected to be in subgraph')
}
const input = graph.inputs?.[0]
if (!input?.labelPos) {
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,
canvasX: input.labelPos[0],
canvasY: input.labelPos[1],
button: 0,
preventDefault: () => {},
stopPropagation: () => {}
@@ -272,7 +252,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
@@ -281,25 +260,21 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
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.describe('Subgraph Slot Rename Dialog', () => {
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
@@ -308,39 +283,27 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@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 expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// 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))
@@ -352,42 +315,29 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_NAME)
expect(afterFirstRename.label).toBe(RENAMED_SLOT_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'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name)
// 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'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// 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)
})
@@ -400,173 +350,193 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@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 expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// 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'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
// 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).toBe(RENAMED_SLOT_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.describe('Subgraph input slot rename propagation', () => {
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
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.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNode).toBeVisible()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
const seedWidget = subgraphNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
// 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()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const seedSlotName = await comfyPage.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
const inputs = (graph as { inputs?: Array<{ name: string }> }).inputs
return (
inputs?.find((input) => input.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' })
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_LABEL)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// 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 subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const updatedLabel = await comfyPage.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')
const widget = node.widgets?.find((entry: { name: string }) =>
entry.name.includes('seed')
)
return w?.label || w?.name || null
return widget?.label || widget?.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)
const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', {
exact: true
})
await expect(seedWidgetAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true })
).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(
subgraphNodeAfter,
seedWidgetAfter
)
})
})
test.describe('Compressed target_slot', () => {
test('Can create widget from link with compressed target_slot', async ({
test.describe('Subgraph promoted widget-input slot position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
'subgraphs/subgraph-with-promoted-text-widget'
)
const step = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes[0].widgets![0].options.step
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
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'
)
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
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(
(input: { type: string }) => input.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
expect(step).toBe(10)
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()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
expect(after!.widgetName).toBe(before!.widgetName)
})
})
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.describe('Subgraph slot alignment after LG layout scale', () => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
@@ -610,91 +580,4 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
}
})
})
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')
})
})
})

View File

@@ -1,6 +1,7 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
@@ -33,11 +34,9 @@ const config: KnipConfig = {
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
'src/styles/global.css'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
}
},
ignoreBinaries: ['python3'],
@@ -54,8 +53,6 @@ const config: KnipConfig = {
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/ingest-types/src/zod.gen.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR

View File

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

627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-oxlint: 1.59.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
@@ -89,14 +89,14 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.0.1
knip: ^6.3.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -1,5 +1,8 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
@@ -8,8 +11,6 @@ import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
@@ -114,8 +115,9 @@ function createWrapper({
}
})
return mount(TopMenuSection, {
attachTo,
const user = userEvent.setup()
const renderOptions: Record<string, unknown> = {
global: {
plugins: [pinia, i18n],
stubs: {
@@ -128,7 +130,8 @@ function createWrapper({
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
template:
'<div data-testid="context-menu" :data-model="JSON.stringify(model)" />'
},
...stubs
},
@@ -136,15 +139,23 @@ function createWrapper({
tooltip: () => {}
}
}
})
}
if (attachTo) {
renderOptions.container = attachTo.appendChild(
document.createElement('div')
)
}
const { container, unmount } = render(TopMenuSection, renderOptions)
return { container, unmount, user }
}
function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
function getLegacyCommandsContainer(container: Element): HTMLElement {
const legacyContainer = container.querySelector(
'[data-testid="legacy-topbar-container"]'
).element
)
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
@@ -201,9 +212,11 @@ describe('TopMenuSection', () => {
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
const { container } = createLegacyTabBarWrapper()
expect(
container.querySelector('current-user-button-stub')
).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
@@ -215,24 +228,24 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('current-user-button-stub')).toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
@@ -242,19 +255,15 @@ describe('TopMenuSection', () => {
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
const queueButton = screen.getByTestId('queue-overlay-toggle')
expect(queueButton.textContent).toContain('3 active')
expect(screen.getByTestId('active-jobs-indicator')).toBeTruthy()
})
it('hides the active jobs indicator when no jobs are active', () => {
const wrapper = createWrapper()
createWrapper()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
@@ -263,16 +272,12 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
expect(screen.getByTestId('queue-overlay-toggle')).toBeTruthy()
expect(container.querySelector('queue-progress-overlay-stub')).toBeNull()
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
@@ -281,10 +286,10 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper({ pinia })
const { user } = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
@@ -297,10 +302,10 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
@@ -311,14 +316,14 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
const toggleButton = screen.getByTestId('queue-overlay-toggle')
await toggleButton.trigger('click')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await toggleButton.trigger('click')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
@@ -341,39 +346,39 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
container.querySelector('queue-inline-progress-summary-stub')
).not.toBeNull()
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('teleports inline progress summary when actionbar is floating', async () => {
@@ -387,7 +392,7 @@ describe('TopMenuSection', () => {
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
const { unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
@@ -401,7 +406,7 @@ describe('TopMenuSection', () => {
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
unmount()
actionbarTarget.remove()
}
})
@@ -424,36 +429,36 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders queue notification banners when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
const html = wrapper.html()
const html = container.innerHTML
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
@@ -477,7 +482,7 @@ describe('TopMenuSection', () => {
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
@@ -493,47 +498,49 @@ describe('TopMenuSection', () => {
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
wrapper
.findComponent({ name: 'QueueNotificationBannerHost' })
.exists()
).toBe(true)
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
} finally {
wrapper.unmount()
unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
const { container } = createWrapper()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const { container } = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
const { container } = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
expect(container.querySelector('span.bg-red-500')).toBeNull()
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
@@ -555,15 +562,19 @@ describe('TopMenuSection', () => {
return undefined
})
const wrapper = createWrapper({ pinia, attachTo: document.body })
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body
})
try {
await nextTick()
const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')
const actionbarContainer = container.querySelector('.actionbar-container')
expect(actionbarContainer).not.toBeNull()
expect(actionbarContainer!.classList).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(wrapper)
const legacyContainer = getLegacyCommandsContainer(container)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
@@ -594,9 +605,9 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer.classes()).toContain('px-2')
expect(actionbarContainer!.classList).toContain('px-2')
} finally {
wrapper.unmount()
unmount()
vi.unstubAllGlobals()
}
})

View File

@@ -1,9 +1,7 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock ShortcutsList component
@@ -12,7 +10,7 @@ vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
name: 'ShortcutsList',
props: ['commands', 'subcategories', 'columns'],
template:
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
'<div data-testid="shortcuts-list">{{ JSON.stringify(subcategories) }}</div>'
}
}))
@@ -56,25 +54,34 @@ describe('EssentialsPanel', () => {
setActivePinia(createPinia())
})
it('should render ShortcutsList with essentials commands', () => {
const wrapper = mount(EssentialsPanel)
it('should render ShortcutsList with essentials commands', async () => {
const { default: EssentialsPanel } =
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
render(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
expect(screen.getByTestId('shortcuts-list')).toBeTruthy()
})
it('should categorize commands into subcategories', () => {
const wrapper = mount(EssentialsPanel)
it('should categorize commands into subcategories', async () => {
const { default: EssentialsPanel } =
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
render(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
const subcategories = shortcutsList.props('subcategories')
const el = screen.getByTestId('shortcuts-list')
const subcategories = JSON.parse(el.textContent ?? '{}')
expect(subcategories).toHaveProperty('workflow')
expect(subcategories).toHaveProperty('node')
expect(subcategories).toHaveProperty('queue')
expect(subcategories.workflow).toContain(mockCommands[0])
expect(subcategories.node).toContain(mockCommands[1])
expect(subcategories.queue).toContain(mockCommands[2])
expect(subcategories.workflow).toContainEqual(
expect.objectContaining({ id: 'Workflow.New' })
)
expect(subcategories.node).toContainEqual(
expect.objectContaining({ id: 'Node.Add' })
)
expect(subcategories.queue).toContainEqual(
expect.objectContaining({ id: 'Queue.Clear' })
)
})
})

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
@@ -64,36 +65,31 @@ describe('ShortcutsList', () => {
}
it('should render shortcuts organized by subcategories', () => {
const wrapper = mount(ShortcutsList, {
render(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check that subcategories are rendered
expect(wrapper.text()).toContain('Workflow')
expect(wrapper.text()).toContain('Node')
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Blank Workflow')
expect(screen.getByText('Workflow')).toBeInTheDocument()
expect(screen.getByText('Node')).toBeInTheDocument()
expect(screen.getByText('Queue')).toBeInTheDocument()
expect(screen.getByText('New Blank Workflow')).toBeInTheDocument()
})
it('should format keyboard shortcuts correctly', () => {
const wrapper = mount(ShortcutsList, {
const { container } = render(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check for formatted keys
expect(wrapper.text()).toContain('Ctrl')
expect(wrapper.text()).toContain('n')
expect(wrapper.text()).toContain('Shift')
expect(wrapper.text()).toContain('a')
expect(wrapper.text()).toContain('c')
const text = container.textContent!
expect(text).toContain('Ctrl')
expect(text).toContain('n')
expect(text).toContain('Shift')
expect(text).toContain('a')
expect(text).toContain('c')
})
it('should filter out commands without keybindings', () => {
@@ -107,9 +103,8 @@ describe('ShortcutsList', () => {
} as ComfyCommandImpl
]
const wrapper = mount(ShortcutsList, {
render(ShortcutsList, {
props: {
commands: commandsWithoutKeybinding,
subcategories: {
...mockSubcategories,
other: [commandsWithoutKeybinding[3]]
@@ -117,7 +112,7 @@ describe('ShortcutsList', () => {
}
})
expect(wrapper.text()).not.toContain('No Keybinding')
expect(screen.queryByText('No Keybinding')).not.toBeInTheDocument()
})
it('should handle special key formatting', () => {
@@ -132,16 +127,15 @@ describe('ShortcutsList', () => {
}
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {
const { container } = render(ShortcutsList, {
props: {
commands: [specialKeyCommand],
subcategories: {
special: [specialKeyCommand]
}
}
})
const text = wrapper.text()
const text = container.textContent!
expect(text).toContain('Cmd') // Meta -> Cmd
expect(text).toContain('↑') // ArrowUp -> ↑
expect(text).toContain('↵') // Enter -> ↵
@@ -150,15 +144,14 @@ describe('ShortcutsList', () => {
})
it('should use fallback subcategory titles', () => {
const wrapper = mount(ShortcutsList, {
render(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: {
unknown: [mockCommands[0]]
}
}
})
expect(wrapper.text()).toContain('unknown')
expect(screen.getByText('unknown')).toBeInTheDocument()
})
})

View File

@@ -1,8 +1,9 @@
/* eslint-disable testing-library/no-node-access */
/* eslint-disable testing-library/prefer-user-event */
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -67,9 +68,10 @@ vi.mock('@/platform/distribution/types', () => ({
}))
// Mock clipboard API
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined)
writeText: mockWriteText
},
configurable: true
})
@@ -87,8 +89,9 @@ const i18n = createI18n({
}
})
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
function renderBaseTerminal(props: Record<string, unknown> = {}) {
return render(BaseTerminal, {
props,
global: {
plugins: [
createTestingPinia({
@@ -107,68 +110,60 @@ const mountBaseTerminal = () => {
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
const onCreated = vi.fn()
renderBaseTerminal({ onCreated })
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
expect(onCreated).toHaveBeenCalled()
expect(onCreated.mock.calls[0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
wrapper = mountBaseTerminal()
wrapper.unmount()
const onUnmounted = vi.fn()
const { unmount } = renderBaseTerminal({ onUnmounted })
unmount()
expect(wrapper.emitted('unmounted')).toBeTruthy()
expect(onUnmounted).toHaveBeenCalled()
})
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
it('button exists and has correct initial state', () => {
renderBaseTerminal()
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
const button = screen.getByRole('button')
expect(button).toHaveClass('opacity-0', 'pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-label', 'Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
const selectionCallback = mockCalls[0][0] as () => void
selectionCallback()
await nextTick()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-label', 'Copy selection')
})
it('copies selected text when selection exists', async () => {
@@ -176,16 +171,17 @@ describe('BaseTerminal', () => {
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockWriteText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
@@ -196,16 +192,17 @@ describe('BaseTerminal', () => {
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockWriteText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
@@ -213,15 +210,16 @@ describe('BaseTerminal', () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
expect(mockWriteText).not.toHaveBeenCalled()
})
})

View File

@@ -1,44 +1,44 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
render(Badge, { props: { label: 'NEW' } })
expect(screen.getByText('NEW')).toBeInTheDocument()
})
it('renders numeric label', () => {
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
render(Badge, { props: { label: 5 } })
expect(screen.getByText('5')).toBeInTheDocument()
})
it('defaults to dot variant when no label is provided', () => {
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
const { container } = render(Badge)
// eslint-disable-next-line testing-library/no-node-access -- dot badge has no text/role to query
expect(container.firstElementChild).toHaveClass('size-2')
})
it('defaults to label variant when label is provided', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
render(Badge, { props: { label: 'NEW' } })
const el = screen.getByText('NEW')
expect(el).toHaveClass('font-semibold')
expect(el).toHaveClass('uppercase')
})
it('applies circle variant', () => {
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
render(Badge, { props: { label: '3', variant: 'circle' } })
expect(screen.getByText('3')).toHaveClass('size-3.5')
})
it('merges custom class via cn()', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
render(Badge, { props: { label: 'Test', class: 'ml-2' } })
const el = screen.getByText('Test')
expect(el).toHaveClass('ml-2')
expect(el).toHaveClass('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
@@ -58,12 +58,10 @@ describe('Badge', () => {
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
render(Badge, { props: { label: 'Test', severity: 'danger' } })
const el = screen.getByText('Test')
expect(el).toHaveClass('text-white')
expect(el).toHaveClass('text-3xs')
})
})
})

View File

@@ -1,22 +1,22 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import MarqueeLine from './MarqueeLine.vue'
describe(MarqueeLine, () => {
it('renders slot content', () => {
const wrapper = mount(MarqueeLine, {
render(MarqueeLine, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
expect(screen.getByText('Hello World')).toBeInTheDocument()
})
it('renders content inside a span within the container', () => {
const wrapper = mount(MarqueeLine, {
render(MarqueeLine, {
slots: { default: 'Test Text' }
})
const span = wrapper.find('span')
expect(span.exists()).toBe(true)
expect(span.text()).toBe('Test Text')
const el = screen.getByText('Test Text')
expect(el.tagName).toBe('SPAN')
})
})

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import NotificationPopup from './NotificationPopup.vue'
@@ -13,13 +13,11 @@ const i18n = createI18n({
}
})
function mountPopup(
props: ComponentProps<typeof NotificationPopup> = {
title: 'Test'
},
function renderPopup(
props: { title: string; [key: string]: unknown } = { title: 'Test' },
slots: Record<string, string> = {}
) {
return mount(NotificationPopup, {
return render(NotificationPopup, {
global: { plugins: [i18n] },
props,
slots
@@ -28,51 +26,58 @@ function mountPopup(
describe('NotificationPopup', () => {
it('renders title', () => {
const wrapper = mountPopup({ title: 'Hello World' })
expect(wrapper.text()).toContain('Hello World')
renderPopup({ title: 'Hello World' })
expect(screen.getByRole('status')).toHaveTextContent('Hello World')
})
it('has role="status" for accessibility', () => {
const wrapper = mountPopup()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
renderPopup()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders subtitle when provided', () => {
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(wrapper.text()).toContain('v1.2.3')
renderPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(screen.getByRole('status')).toHaveTextContent('v1.2.3')
})
it('renders icon when provided', () => {
const wrapper = mountPopup({
const { container } = renderPopup({
title: 'T',
icon: 'icon-[lucide--rocket]'
})
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('i.icon-\\[lucide--rocket\\]')
expect(icon).toBeInTheDocument()
})
it('emits close when close button clicked', async () => {
const wrapper = mountPopup({ title: 'T', showClose: true })
await wrapper.find('[aria-label="Close"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
const user = userEvent.setup()
const closeSpy = vi.fn()
renderPopup({ title: 'T', showClose: true, onClose: closeSpy })
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(closeSpy).toHaveBeenCalledOnce()
})
it('renders default slot content', () => {
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
expect(wrapper.text()).toContain('Body text here')
renderPopup({ title: 'T' }, { default: 'Body text here' })
expect(screen.getByRole('status')).toHaveTextContent('Body text here')
})
it('renders footer slots', () => {
const wrapper = mountPopup(
renderPopup(
{ title: 'T' },
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
)
expect(wrapper.text()).toContain('Left side')
expect(wrapper.text()).toContain('Right side')
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Left side')
expect(status).toHaveTextContent('Right side')
})
it('positions bottom-right when specified', () => {
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
const root = wrapper.find('[role="status"]')
expect(root.attributes('data-position')).toBe('bottom-right')
renderPopup({ title: 'T', position: 'bottom-right' })
expect(screen.getByRole('status')).toHaveAttribute(
'data-position',
'bottom-right'
)
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,8 @@ function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
describe(TextTicker, () => {
let rafCallbacks: ((time: number) => void)[]
let wrapper: ReturnType<typeof mount>
let user: ReturnType<typeof userEvent.setup>
let cleanup: (() => void) | undefined
beforeEach(() => {
vi.useFakeTimers()
@@ -23,32 +25,35 @@ describe(TextTicker, () => {
return rafCallbacks.length
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
})
afterEach(() => {
wrapper?.unmount()
cleanup?.()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('renders slot content', () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
cleanup = unmount
expect(screen.getByText('Hello World')).toBeInTheDocument()
})
it('scrolls on hover after delay', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = wrapper.element as HTMLElement
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
expect(rafCallbacks.length).toBe(0)
@@ -62,19 +67,21 @@ describe(TextTicker, () => {
})
it('cancels delayed scroll on mouse leave before delay elapses', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
mockScrollWidth(wrapper.element as HTMLElement, 300)
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
vi.advanceTimersByTime(200)
await wrapper.trigger('mouseleave')
await user.unhover(el)
await nextTick()
vi.advanceTimersByTime(350)
@@ -83,16 +90,17 @@ describe(TextTicker, () => {
})
it('resets scroll position on mouse leave', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = wrapper.element as HTMLElement
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
@@ -100,19 +108,22 @@ describe(TextTicker, () => {
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
await wrapper.trigger('mouseleave')
await user.unhover(el)
await nextTick()
expect(el.scrollLeft).toBe(0)
})
it('does not scroll when content fits', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Short' }
})
cleanup = unmount
const el = screen.getByText('Short')
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()

View File

@@ -1,8 +1,7 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
import TextTickerMultiLine from './TextTickerMultiLine.vue'
type Callback = () => void
@@ -41,23 +40,38 @@ function mockElementSize(
}
describe(TextTickerMultiLine, () => {
let wrapper: ReturnType<typeof mount>
let unmountFn: () => void
afterEach(() => {
wrapper?.unmount()
unmountFn?.()
resizeCallbacks.length = 0
mutationCallbacks.length = 0
})
function mountComponent(text: string) {
wrapper = mount(TextTickerMultiLine, {
function renderComponent(text: string) {
const result = render(TextTickerMultiLine, {
slots: { default: text }
})
return wrapper
unmountFn = result.unmount
return {
...result,
container: result.container as HTMLElement
}
}
function getMeasureEl(): HTMLElement {
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
function getMeasureEl(container: HTMLElement): HTMLElement {
// eslint-disable-next-line testing-library/no-node-access
return container.querySelector('[aria-hidden="true"]') as HTMLElement
}
function getVisibleLines(container: HTMLElement): HTMLElement[] {
/* eslint-disable testing-library/no-node-access */
return Array.from(
container.querySelectorAll<HTMLElement>(
'div.overflow-hidden:not([aria-hidden])'
)
)
/* eslint-enable testing-library/no-node-access */
}
async function triggerSplitLines() {
@@ -66,40 +80,42 @@ describe(TextTickerMultiLine, () => {
}
it('renders slot content', () => {
mountComponent('Load Checkpoint')
expect(wrapper.text()).toContain('Load Checkpoint')
renderComponent('Load Checkpoint')
expect(
screen.getAllByText('Load Checkpoint').length
).toBeGreaterThanOrEqual(1)
})
it('renders a single MarqueeLine when text fits', async () => {
mountComponent('Short')
mockElementSize(getMeasureEl(), 200, 100)
it('renders a single line when text fits', async () => {
const { container } = renderComponent('Short')
mockElementSize(getMeasureEl(container), 200, 100)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
expect(getVisibleLines(container)).toHaveLength(1)
})
it('renders two MarqueeLines when text overflows', async () => {
mountComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(), 100, 300)
it('renders two lines when text overflows', async () => {
const { container } = renderComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(container), 100, 300)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
expect(getVisibleLines(container)).toHaveLength(2)
})
it('splits text at word boundary when overflowing', async () => {
mountComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(), 100, 200)
const { container } = renderComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(container), 100, 200)
await triggerSplitLines()
const lines = wrapper.findAllComponents(MarqueeLine)
expect(lines[0].text()).toBe('Load')
expect(lines[1].text()).toBe('Checkpoint Loader')
const lines = getVisibleLines(container)
expect(lines[0].textContent).toBe('Load')
expect(lines[1].textContent).toBe('Checkpoint Loader')
})
it('has hidden measurement element with aria-hidden', () => {
mountComponent('Test')
const measureEl = wrapper.find('[aria-hidden="true"]')
expect(measureEl.exists()).toBe(true)
expect(measureEl.classes()).toContain('invisible')
const { container } = renderComponent('Test')
const measureEl = getMeasureEl(container)
expect(measureEl).toBeInTheDocument()
expect(measureEl).toHaveClass('invisible')
})
})

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -92,7 +93,7 @@ describe('TreeExplorerV2Node', () => {
}
}
function mountComponent(
function renderComponent(
props: Record<string, unknown> = {},
options: {
provide?: Record<string, unknown>
@@ -100,68 +101,76 @@ describe('TreeExplorerV2Node', () => {
} = {}
) {
const treeItemStub = options.treeItemStub ?? createTreeItemStub()
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }
},
provide: {
...options.provide
}
const onNodeClick = vi.fn()
const { container } = render(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }
},
props: {
item: createMockItem('node'),
...props
provide: {
...options.provide
}
}),
treeItemStub
}
},
props: {
item: createMockItem('node'),
onNodeClick,
...props
}
})
return { container, treeItemStub, onNodeClick }
}
function getTreeNode(container: Element) {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
return container.querySelector('div.group\\/tree-node')! as HTMLElement
}
describe('handleClick', () => {
it('emits nodeClick event when clicked', async () => {
const { wrapper } = mountComponent({
const user = userEvent.setup()
const { container, onNodeClick } = renderComponent({
item: createMockItem('node')
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
const nodeDiv = getTreeNode(container)
await user.click(nodeDiv)
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({
expect(onNodeClick).toHaveBeenCalled()
expect(onNodeClick.mock.calls[0][0]).toMatchObject({
type: 'node',
label: 'Test Label'
})
})
it('calls handleToggle for folder items', async () => {
const user = userEvent.setup()
const treeItemStub = createTreeItemStub()
const { wrapper } = mountComponent(
const { container, onNodeClick } = renderComponent(
{ item: createMockItem('folder') },
{ treeItemStub }
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('click')
const folderDiv = getTreeNode(container)
await user.click(folderDiv)
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(onNodeClick).toHaveBeenCalled()
expect(treeItemStub.handleToggle).toHaveBeenCalled()
})
it('does not call handleToggle for node items', async () => {
const user = userEvent.setup()
const treeItemStub = createTreeItemStub()
const { wrapper } = mountComponent(
const { container, onNodeClick } = renderComponent(
{ item: createMockItem('node') },
{ treeItemStub }
)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
const nodeDiv = getTreeNode(container)
await user.click(nodeDiv)
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(onNodeClick).toHaveBeenCalled()
expect(treeItemStub.handleToggle).not.toHaveBeenCalled()
})
})
@@ -171,7 +180,7 @@ describe('TreeExplorerV2Node', () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const nodeItem = createMockItem('node')
const { wrapper } = mountComponent(
const { container } = renderComponent(
{ item: nodeItem },
{
provide: {
@@ -180,8 +189,8 @@ describe('TreeExplorerV2Node', () => {
}
)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('contextmenu')
const nodeDiv = getTreeNode(container)
await fireEvent.contextMenu(nodeDiv)
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
@@ -193,7 +202,7 @@ describe('TreeExplorerV2Node', () => {
label: 'Stale'
} as RenderedTreeExplorerNode)
const { wrapper } = mountComponent(
const { container } = renderComponent(
{ item: createMockItem('folder') },
{
provide: {
@@ -202,8 +211,8 @@ describe('TreeExplorerV2Node', () => {
}
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('contextmenu')
const folderDiv = getTreeNode(container)
await fireEvent.contextMenu(folderDiv)
expect(contextMenuNode.value).toBeNull()
})
@@ -216,47 +225,53 @@ describe('TreeExplorerV2Node', () => {
it('shows delete button for user blueprints', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it('hides delete button for non-blueprint nodes', () => {
mockIsUserBlueprint.mockReturnValue(false)
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: 'KSampler' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
expect(
screen.queryByRole('button', { name: 'Delete' })
).not.toBeInTheDocument()
})
it('always shows bookmark button', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
expect(
screen.getByRole('button', { name: 'icon.bookmark' })
).toBeInTheDocument()
})
it('calls deleteBlueprint when delete button is clicked', async () => {
const user = userEvent.setup()
mockIsUserBlueprint.mockReturnValue(true)
const nodeName = 'SubgraphBlueprint.test'
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: nodeName }
})
})
await wrapper.find('[aria-label="Delete"]').trigger('click')
const deleteButton = screen.getByRole('button', { name: 'Delete' })
await user.click(deleteButton)
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
})
@@ -264,40 +279,47 @@ describe('TreeExplorerV2Node', () => {
describe('rendering', () => {
it('renders node icon for node type', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node')
})
expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('i.icon-\\[comfy--node\\]')).toBeTruthy()
})
it('renders folder icon for folder type', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('folder', { icon: 'icon-[lucide--folder]' })
})
expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(
container.querySelector('i.icon-\\[lucide--folder\\]')
).toBeTruthy()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
it('renders label text', () => {
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', { label: 'My Node' })
})
expect(wrapper.text()).toContain('My Node')
expect(screen.getByText('My Node')).toBeInTheDocument()
})
it('renders chevron for folder with children', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: {
...createMockItem('folder'),
hasChildren: true
}
})
expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe(
true
)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(
container.querySelector('i.icon-\\[lucide--chevron-down\\]')
).toBeTruthy()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
})
@@ -307,75 +329,75 @@ describe('TreeExplorerV2Node', () => {
})
it('sets draggable attribute on node items', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node')
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
expect(nodeDiv.attributes('draggable')).toBe('true')
const nodeDiv = getTreeNode(container)
expect(nodeDiv.getAttribute('draggable')).toBe('true')
})
it('does not set draggable on folder items', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('folder')
})
const folderDiv = wrapper.find('div.group\\/tree-node')
expect(folderDiv.attributes('draggable')).toBeUndefined()
const folderDiv = getTreeNode(container)
expect(folderDiv.getAttribute('draggable')).toBeNull()
})
it('calls startDrag with native mode on dragstart', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('dragstart')
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
})
it('does not call startDrag for folder items on dragstart', async () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('folder')
})
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('dragstart')
const folderDiv = getTreeNode(container)
await fireEvent.dragStart(folderDiv)
expect(mockStartDrag).not.toHaveBeenCalled()
})
it('calls handleNativeDrop on dragend with drop coordinates', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
const nodeDiv = getTreeNode(container)
await nodeDiv.trigger('dragstart')
await fireEvent.dragStart(nodeDiv)
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
nodeDiv.dispatchEvent(dragEndEvent)
await nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
})
it('calls handleNativeDrop regardless of dropEffect', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
const nodeDiv = getTreeNode(container)
await nodeDiv.trigger('dragstart')
await fireEvent.dragStart(nodeDiv)
mockHandleNativeDrop.mockClear()
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
@@ -385,8 +407,8 @@ describe('TreeExplorerV2Node', () => {
value: { dropEffect: 'none' }
})
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
nodeDiv.dispatchEvent(dragEndEvent)
await nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
@@ -46,7 +46,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -60,16 +60,14 @@ describe('VirtualGrid', () => {
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
const renderedItems = screen.getAllByText(/^Item \d+$/)
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
wrapper.unmount()
})
it('provides correct index in slot props', async () => {
@@ -79,7 +77,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -94,7 +92,7 @@ describe('VirtualGrid', () => {
return null
}
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
@@ -104,8 +102,6 @@ describe('VirtualGrid', () => {
for (let i = 1; i < receivedIndices.length; i++) {
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
}
wrapper.unmount()
})
it('respects maxColumns prop', async () => {
@@ -114,28 +110,29 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
const { container } = render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
maxColumns: 2
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const gridElement = wrapper.find('[style*="display: grid"]')
expect(gridElement.exists()).toBe(true)
const gridEl = gridElement.element as HTMLElement
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
wrapper.unmount()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const gridElement = container.querySelector(
'[style*="display: grid"]'
) as HTMLElement
expect(gridElement).not.toBeNull()
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(2, minmax(0, 1fr))'
)
})
it('renders empty when no items provided', async () => {
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items: [],
gridStyle: defaultGridStyle
@@ -149,10 +146,8 @@ describe('VirtualGrid', () => {
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
const renderedItems = screen.queryAllByText(/^Item \d+$/)
expect(renderedItems.length).toBe(0)
wrapper.unmount()
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
@@ -161,7 +156,9 @@ describe('VirtualGrid', () => {
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items,
gridStyle: {
@@ -171,19 +168,20 @@ describe('VirtualGrid', () => {
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1
bufferRows: 1,
onApproachEnd
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
expect(wrapper.emitted('approach-end')).toBeUndefined()
expect(onApproachEnd).not.toHaveBeenCalled()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
@@ -195,9 +193,7 @@ describe('VirtualGrid', () => {
mockedScrollY.value = 1680
await nextTick()
expect(wrapper.emitted('approach-end')).toBeDefined()
wrapper.unmount()
expect(onApproachEnd).toHaveBeenCalled()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
@@ -208,7 +204,9 @@ describe('VirtualGrid', () => {
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items,
gridStyle: {
@@ -218,14 +216,15 @@ describe('VirtualGrid', () => {
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
bufferRows: 1
bufferRows: 1,
onApproachEnd
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
@@ -237,9 +236,7 @@ describe('VirtualGrid', () => {
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
expect(wrapper.emitted('approach-end')).toBeUndefined()
wrapper.unmount()
expect(onApproachEnd).not.toHaveBeenCalled()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
@@ -248,7 +245,7 @@ describe('VirtualGrid', () => {
mockedScrollY.value = 0
const items = createItems(20)
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -262,15 +259,13 @@ describe('VirtualGrid', () => {
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
const renderedItems = screen.getAllByText(/^Item \d+$/)
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length % 4).toBe(0)
wrapper.unmount()
})
})

View File

@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -7,10 +8,23 @@ import type {
WorkflowMenuItem
} from '@/types/workflowMenuItem'
function createWrapper(items: WorkflowMenuItem[]) {
return shallowMount(WorkflowActionsList, {
props: { items },
global: { renderStubDefaultSlot: true }
const MenuItemStub = {
template:
'<div data-testid="menu-item" @click="$emit(\'select\')"><slot /></div>',
emits: ['select']
}
const SeparatorStub = {
template: '<hr data-testid="menu-separator" />'
}
function renderList(items: WorkflowMenuItem[]) {
return render(WorkflowActionsList, {
props: {
items,
itemComponent: MenuItemStub,
separatorComponent: SeparatorStub
}
})
}
@@ -20,10 +34,9 @@ describe('WorkflowActionsList', () => {
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).toContain('Save')
expect(wrapper.find('.pi-save').exists()).toBe(true)
expect(screen.getByText('Save')).toBeInTheDocument()
})
it('renders separator items', () => {
@@ -33,24 +46,23 @@ describe('WorkflowActionsList', () => {
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
const html = wrapper.html()
renderList(items)
expect(html).toContain('dropdown-menu-separator-stub')
expect(wrapper.text()).toContain('Before')
expect(wrapper.text()).toContain('After')
screen.getByTestId('menu-separator')
screen.getByText('Before')
screen.getByText('After')
})
it('dispatches command on select', async () => {
const user = userEvent.setup()
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
await item.vm.$emit('select')
renderList(items)
await user.click(screen.getByTestId('menu-item'))
expect(command).toHaveBeenCalledOnce()
})
@@ -65,9 +77,9 @@ describe('WorkflowActionsList', () => {
}
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).toContain('NEW')
screen.getByText('NEW')
})
it('does not render items with visible set to false', () => {
@@ -82,10 +94,10 @@ describe('WorkflowActionsList', () => {
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
expect(screen.queryByText('Hidden Item')).toBeNull()
screen.getByText('Shown Item')
})
it('does not render badge when absent', () => {
@@ -93,8 +105,8 @@ describe('WorkflowActionsList', () => {
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).not.toContain('NEW')
expect(screen.queryByText('NEW')).toBeNull()
})
})

View File

@@ -1,86 +1,91 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'
function mountEditor(points: CurvePoint[], extraProps = {}) {
return mount(CurveEditor, {
function renderEditor(points: CurvePoint[], extraProps = {}) {
const { container } = render(CurveEditor, {
props: { modelValue: points, ...extraProps }
})
return { container }
}
function getCurvePath(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-testid="curve-path"]')
function getCurvePath() {
return screen.getByTestId('curve-path')
}
describe('CurveEditor', () => {
it('renders SVG with curve path', () => {
const wrapper = mountEditor([
const { container } = renderEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('svg').exists()).toBe(true)
const curvePath = getCurvePath(wrapper)
expect(curvePath.exists()).toBe(true)
expect(curvePath.attributes('d')).toBeTruthy()
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelector('svg')).toBeInTheDocument()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
const curvePath = getCurvePath()
expect(curvePath).toBeInTheDocument()
expect(curvePath.getAttribute('d')).toBeTruthy()
})
it('renders a circle for each control point', () => {
const wrapper = mountEditor([
const { container } = renderEditor([
[0, 0],
[0.5, 0.7],
[1, 1]
])
expect(wrapper.findAll('circle')).toHaveLength(3)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelectorAll('circle')).toHaveLength(3)
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
it('renders histogram path when provided', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const wrapper = mountEditor(
renderEditor(
[
[0, 0],
[1, 1]
],
{ histogram }
)
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
expect(histogramPath.exists()).toBe(true)
expect(histogramPath.attributes('d')).toContain('M0,1')
const histogramPath = screen.getByTestId('histogram-path')
expect(histogramPath).toBeInTheDocument()
expect(histogramPath.getAttribute('d')).toContain('M0,1')
})
it('does not render histogram path when not provided', () => {
const wrapper = mountEditor([
renderEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
expect(screen.queryByTestId('histogram-path')).not.toBeInTheDocument()
})
it('returns empty path with fewer than 2 points', () => {
const wrapper = mountEditor([[0.5, 0.5]])
expect(getCurvePath(wrapper).attributes('d')).toBe('')
renderEditor([[0.5, 0.5]])
expect(getCurvePath().getAttribute('d')).toBe('')
})
it('generates path starting with M and containing L segments', () => {
const wrapper = mountEditor([
renderEditor([
[0, 0],
[0.5, 0.8],
[1, 1]
])
const d = getCurvePath(wrapper).attributes('d')!
const d = getCurvePath().getAttribute('d')!
expect(d).toMatch(/^M/)
expect(d).toContain('L')
})
it('curve path only spans the x-range of control points', () => {
const wrapper = mountEditor([
renderEditor([
[0.2, 0.3],
[0.8, 0.9]
])
const d = getCurvePath(wrapper).attributes('d')!
const d = getCurvePath().getAttribute('d')!
const xValues = d
.split(/[ML]/)
.filter(Boolean)
@@ -95,19 +100,22 @@ describe('CurveEditor', () => {
[0.5, 0.5],
[1, 1]
]
const wrapper = mountEditor(points)
expect(wrapper.findAll('circle')).toHaveLength(3)
const { container } = renderEditor(points)
await wrapper.findAll('circle')[1].trigger('pointerdown', {
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
expect(container.querySelectorAll('circle')).toHaveLength(3)
await fireEvent.pointerDown(container.querySelectorAll('circle')[1], {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
expect(container.querySelectorAll('circle')).toHaveLength(2)
await wrapper.findAll('circle')[0].trigger('pointerdown', {
await fireEvent.pointerDown(container.querySelectorAll('circle')[0], {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
expect(container.querySelectorAll('circle')).toHaveLength(2)
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
})

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
@@ -10,10 +11,16 @@ const i18n = createI18n({
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
function renderOption(
props?: Partial<{
credits: number
description: string
selected: boolean
onSelect: () => void
}>
) {
const user = userEvent.setup()
const result = render(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
@@ -24,25 +31,30 @@ const mountOption = (
plugins: [i18n]
}
})
return { user, ...result }
}
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
renderOption({ credits: 5000, description: '~500 videos*' })
expect(screen.getByText('5,000')).toBeInTheDocument()
expect(screen.getByText('~500 videos*')).toBeInTheDocument()
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
const { container } = renderOption({ selected: false })
// eslint-disable-next-line testing-library/no-node-access
const rootDiv = container.firstElementChild as HTMLElement
expect(rootDiv).toHaveClass(
'bg-component-node-disabled',
'border-transparent'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
const selectSpy = vi.fn()
const { user } = renderOption({ onSelect: selectSpy })
await user.click(screen.getByText('1,000'))
expect(selectSpy).toHaveBeenCalledOnce()
})
})

View File

@@ -1,12 +1,15 @@
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { defineComponent, h } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SettingItem from '@/platform/settings/components/SettingItem.vue'
import type { SettingParams } from '@/platform/settings/types'
const i18n = createI18n({
legacy: false,
@@ -17,60 +20,72 @@ vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: vi.fn()
}))
const FormItemStub = defineComponent({
name: 'FormItem',
props: {
item: { type: Object, default: () => ({}) },
id: { type: String, default: undefined },
formValue: { type: null, default: undefined }
},
setup(props) {
return () =>
h('div', { 'data-testid': 'form-item-data' }, JSON.stringify(props.item))
}
})
describe('SettingItem', () => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
return mount(SettingItem, {
function renderComponent(setting: SettingParams) {
return render(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
components: {
Tag
},
directives: {
tooltip: Tooltip
},
components: { Tag },
stubs: {
FormItem: FormItemStub,
'i-material-symbols:experiment-outline': true
}
},
directives: { tooltip: Tooltip }
},
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
props,
...options
props: { setting }
})
}
function getFormItemData(container: Element) {
// eslint-disable-next-line testing-library/no-node-access
const el = container.querySelector('[data-testid="form-item-data"]')
return JSON.parse(el!.textContent!)
}
it('translates options that use legacy type', () => {
const wrapper = mountComponent({
setting: {
const { container } = renderComponent(
fromAny({
id: 'Comfy.NodeInputConversionSubmenus',
name: 'Node Input Conversion Submenus',
type: 'combo',
value: 'Top',
defaultValue: 'Top',
options: () => ['Correctly Translated']
}
})
})
)
// Check the FormItem component's item prop for the options
const formItem = wrapper.findComponent({ name: 'FormItem' })
const options = formItem.props('item').options
expect(options).toEqual([
const data = getFormItemData(container)
expect(data.options).toEqual([
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
})
it('handles tooltips with @ symbols without errors', () => {
const wrapper = mountComponent({
setting: {
id: 'TestSetting',
const { container } = renderComponent(
fromAny({
id: 'Comfy.NodeInputConversionSubmenus',
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
}
})
})
)
// Should not throw an error and tooltip should be preserved as-is
const formItem = wrapper.findComponent({ name: 'FormItem' })
expect(formItem.props('item').tooltip).toBe(
const data = getFormItemData(container)
expect(data.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})

View File

@@ -1,40 +1,17 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Button from '@/components/ui/button/Button.vue'
import Column from 'primevue/column'
import PrimeVue from 'primevue/config'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { defineComponent, onMounted, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: Partial<AuditLog>[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
dataTableFirst: number
tooltipContentMap: Map<string, string>
loadEvents: () => Promise<void>
refresh: () => Promise<void>
onPageChange: (event: { page: number }) => void
}
// Mock the customerEventsService
const mockCustomerEventsService = vi.hoisted(() => ({
getMyEvents: vi.fn(),
formatEventType: vi.fn(),
@@ -43,7 +20,7 @@ const mockCustomerEventsService = vi.hoisted(() => ({
formatDate: vi.fn(),
hasAdditionalInfo: vi.fn(),
getTooltipContent: vi.fn(),
error: { value: null },
error: { value: null as string | null },
isLoading: { value: false }
}))
@@ -57,7 +34,10 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
// Create i18n instance
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -76,78 +56,115 @@ const i18n = createI18n({
}
})
describe('UsageLogsTable', () => {
const mockEventsResponse = {
events: [
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
],
total: 2,
const globalConfig = {
plugins: [PrimeVue, i18n, createTestingPinia()],
directives: { tooltip: Tooltip }
}
/**
* The component starts with loading=true and only loads data when refresh()
* is called via template ref. This wrapper auto-calls refresh on mount.
*/
const AutoRefreshWrapper = defineComponent({
components: { UsageLogsTable },
setup() {
const tableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
onMounted(async () => {
await tableRef.value?.refresh()
})
return { tableRef }
},
template: '<UsageLogsTable ref="tableRef" />'
})
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
) {
return {
events,
total: events.length,
page: 1,
limit: 7,
totalPages: 1
totalPages: 1,
...overrides
}
}
describe('UsageLogsTable', () => {
const mockEventsResponse = makeEventsResponse([
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
])
beforeEach(() => {
vi.clearAllMocks()
// Setup default service mock implementations
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
}
}
})
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
)
mockCustomerEventsService.getEventSeverity.mockImplementation(
(type: string) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
}
}
})
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
})
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
return new Date(dateString).toLocaleDateString()
})
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
const { amount, api_name, model, ...otherParams } = event.params || {}
return Object.keys(otherParams).length > 0
})
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
return '<strong>Transaction Id:</strong> txn-123'
})
)
mockCustomerEventsService.formatAmount.mockImplementation(
(amount: number) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
}
)
mockCustomerEventsService.formatDate.mockImplementation(
(dateString: string) => new Date(dateString).toLocaleDateString()
)
mockCustomerEventsService.hasAdditionalInfo.mockImplementation(
(event: AuditLog) => {
const { amount, api_name, model, ...otherParams } =
(event.params as Record<string, unknown>) ?? {}
return Object.keys(otherParams).length > 0
}
)
mockCustomerEventsService.getTooltipContent.mockImplementation(
() => '<strong>Transaction Id:</strong> txn-123'
)
mockCustomerEventsService.error.value = null
mockCustomerEventsService.isLoading.value = false
})
@@ -156,200 +173,146 @@ describe('UsageLogsTable', () => {
vi.restoreAllMocks()
})
const mountComponent = (options = {}) => {
return mount(UsageLogsTable, {
global: {
plugins: [PrimeVue, i18n, createTestingPinia()],
components: {
DataTable,
Column,
Badge,
Button,
Message,
ProgressSpinner
},
directives: {
tooltip: Tooltip
}
},
...options
function renderComponent() {
return render(UsageLogsTable, { global: globalConfig })
}
function renderWithAutoRefresh() {
return render(AutoRefreshWrapper, { global: globalConfig })
}
async function renderLoaded() {
const result = renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument()
})
return result
}
describe('loading states', () => {
it('shows loading spinner when loading is true', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = true
await nextTick()
it('shows loading spinner before refresh is called', () => {
renderComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('shows error message when error exists', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.error = 'Failed to load events'
vm.loading = false
await nextTick()
it('shows error message when service returns null', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = 'Failed to load events'
const messageComponent = wrapper.findComponent(Message)
expect(messageComponent.exists()).toBe(true)
expect(messageComponent.props('severity')).toBe('error')
expect(messageComponent.text()).toContain('Failed to load events')
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Failed to load events')).toBeInTheDocument()
})
})
it('shows data table when loaded successfully', async () => {
const wrapper = mountComponent()
it('shows error message when service throws', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
const vm = wrapper.vm as ComponentInstance
// Wait for component to mount and load data
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
renderWithAutoRefresh()
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
})
})
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Message).exists()).toBe(false)
it('shows data table after loading completes', async () => {
await renderLoaded()
expect(
screen.queryByText('Failed to load events')
).not.toBeInTheDocument()
})
})
describe('data rendering', () => {
it('renders events data correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('renders event type badges', async () => {
await renderLoaded()
const dataTable = wrapper.findComponent(DataTable)
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
expect(dataTable.props('rows')).toBe(7)
expect(dataTable.props('paginator')).toBe(true)
expect(dataTable.props('lazy')).toBe(true)
})
it('renders badge for event types correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const badges = wrapper.findAllComponents(Badge)
expect(badges.length).toBeGreaterThan(0)
// Check if formatEventType and getEventSeverity are called
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
})
it('renders different event details based on event type', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('renders credit added details with formatted amount', async () => {
await renderLoaded()
// Check if formatAmount is called for credit_added events
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders tooltip buttons for events with additional info', async () => {
it('renders API usage details with api name and model', async () => {
await renderLoaded()
expect(screen.getByText('Image Generation')).toBeInTheDocument()
expect(screen.getByText(/sdxl-base/)).toBeInTheDocument()
})
it('renders account created details', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-3',
event_type: 'account_created',
params: {},
createdAt: '2024-01-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Account initialized')).toBeInTheDocument()
})
})
it('renders formatted dates', async () => {
await renderLoaded()
expect(mockCustomerEventsService.formatDate).toHaveBeenCalled()
})
it('renders info buttons for events with additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
await renderLoaded()
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
const infoButtons = screen.getAllByRole('button', {
name: 'Additional Info'
})
expect(infoButtons.length).toBeGreaterThan(0)
})
it('does not render info buttons when no additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
await renderLoaded()
expect(
screen.queryByRole('button', { name: 'Additional Info' })
).not.toBeInTheDocument()
})
})
describe('pagination', () => {
it('handles page change correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('calls getMyEvents with initial page params', async () => {
await renderLoaded()
// Simulate page change
const dataTable = wrapper.findComponent(DataTable)
await dataTable.vm.$emit('page', { page: 1 })
expect(vm.pagination.page).toBe(1) // page + 1
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 2,
page: 1,
limit: 7
})
})
it('calculates dataTableFirst correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
await nextTick()
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
})
})
describe('tooltip functionality', () => {
it('generates tooltip content map correctly', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
mockCustomerEventsService.getTooltipContent.mockReturnValue(
'<strong>Test:</strong> value'
)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
})
it('excludes events without additional info from tooltip map', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.size).toBe(0)
})
})
describe('component methods', () => {
it('exposes refresh method', () => {
const wrapper = mountComponent()
it('calls getMyEvents on refresh with page 1', async () => {
await renderLoaded()
expect(typeof wrapper.vm.refresh).toBe('function')
})
it('resets to first page on refresh', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination.page = 3
await vm.refresh()
expect(vm.pagination.page).toBe(1)
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
@@ -357,44 +320,41 @@ describe('UsageLogsTable', () => {
})
})
describe('component lifecycle', () => {
it('initializes with correct default values', () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
expect(vm.events).toEqual([])
expect(vm.loading).toBe(true)
expect(vm.error).toBeNull()
expect(vm.pagination).toEqual({
page: 1,
limit: 7,
total: 0,
totalPages: 0
})
})
})
describe('EventType integration', () => {
it('uses EventType enum in template conditions', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
it('renders credit_added event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
vm.loading = false
vm.events = [
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
]
await nextTick()
await renderLoaded()
// Verify that the component can access EventType enum
expect(EventType.CREDIT_ADDED).toBe('credit_added')
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders api_usage_completed event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-2',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'Test API', model: 'test-model' },
createdAt: '2024-01-02T10:00:00Z'
}
])
)
await renderLoaded()
expect(screen.getByText('Test API')).toBeInTheDocument()
expect(screen.getByText(/test-model/)).toBeInTheDocument()
})
})
})

View File

@@ -1,14 +1,12 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
@@ -16,11 +14,13 @@ import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
const mockLoadingRef = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
loading: mockLoading()
get loading() {
return mockLoadingRef.value
}
}))
}))
@@ -58,62 +58,57 @@ const i18n = createI18n({
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
vi.clearAllMocks()
mockStoreApiKey.mockReset()
mockLoading.mockReset()
mockLoadingRef.value = false
})
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
return mount(ApiKeyForm, {
function renderComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const result = render(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
plugins: [PrimeVue, i18n],
components: { Button, Form, InputText, Message }
},
props
})
return { ...result, user }
}
it('renders correctly with all required elements', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.find('h1').text()).toBe('API Key')
expect(wrapper.find('label').text()).toBe('API Key')
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
expect(screen.getByRole('heading', { name: 'API Key' })).toBeInTheDocument()
expect(screen.getByLabelText('API Key')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('emits back event when back button is clicked', async () => {
const wrapper = mountComponent()
const onBack = vi.fn()
const { user } = renderComponent({ onBack })
await wrapper.findComponent(Button).trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(onBack).toHaveBeenCalled()
})
it('shows loading state when submitting', async () => {
mockLoading.mockReturnValue(true)
const wrapper = mountComponent()
const input = wrapper.findComponent(InputText)
it('shows loading state when submitting', () => {
mockLoadingRef.value = true
const { container } = renderComponent()
await input.setValue(
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
)
await wrapper.find('form').trigger('submit')
const buttons = wrapper.findAllComponents(Button)
const submitButton = buttons.find(
(btn) => btn.attributes('type') === 'submit'
)
expect(submitButton?.props('loading')).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const submitButton = container.querySelector('button[type="submit"]')
expect(submitButton).toBeDisabled()
})
it('displays help text and links correctly', () => {
const wrapper = mountComponent()
renderComponent()
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
expect(
screen.getByText('Need an API key?', { exact: false })
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Get one here' })).toHaveAttribute(
'href',
`${getComfyPlatformBaseUrl()}/login`
)
})

View File

@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GradientSlider from './GradientSlider.vue'
import { render, screen } from '@testing-library/vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import GradientSlider from './GradientSlider.vue'
import { interpolateStops, stopsToGradient } from './gradients'
const TEST_STOPS: ColorStop[] = [
@@ -10,40 +12,44 @@ const TEST_STOPS: ColorStop[] = [
{ offset: 1, color: [255, 255, 255] }
]
function mountSlider(props: {
function renderSlider(props: {
stops?: ColorStop[]
modelValue: number
min?: number
max?: number
step?: number
}) {
return mount(GradientSlider, {
return render(GradientSlider, {
props: { stops: TEST_STOPS, ...props }
})
}
describe('GradientSlider', () => {
it('passes min, max, step to SliderRoot', () => {
const wrapper = mountSlider({
it('passes min and max to SliderRoot', () => {
renderSlider({
modelValue: 50,
min: -100,
max: 100,
step: 5
})
const thumb = wrapper.find('[role="slider"]')
expect(thumb.attributes('aria-valuemin')).toBe('-100')
expect(thumb.attributes('aria-valuemax')).toBe('100')
const thumb = screen.getByRole('slider', { hidden: true })
expect(thumb).toBeInTheDocument()
expect(thumb).toHaveAttribute('aria-valuemin', '-100')
expect(thumb).toHaveAttribute('aria-valuemax', '100')
})
it('renders slider root with track and thumb', () => {
const wrapper = mountSlider({ modelValue: 0 })
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
const { container } = renderSlider({ modelValue: 0 })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('[data-slider-impl]')).toBeInTheDocument()
expect(screen.getByRole('slider', { hidden: true })).toBeInTheDocument()
})
it('does not render SliderRange', () => {
const wrapper = mountSlider({ modelValue: 50 })
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
const { container } = renderSlider({ modelValue: 50 })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const range = container.querySelector('[data-slot="slider-range"]')
expect(range).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
@@ -44,8 +45,9 @@ const i18n = createI18n({
const mockPopoverHide = vi.fn()
function createWrapper() {
return mount(CanvasModeSelector, {
function renderComponent() {
const user = userEvent.setup()
render(CanvasModeSelector, {
global: {
plugins: [i18n],
stubs: {
@@ -59,94 +61,98 @@ function createWrapper() {
}
}
})
return { user }
}
describe('CanvasModeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render menu with menuitemradio roles and aria-checked', () => {
const wrapper = createWrapper()
renderComponent()
const menu = wrapper.find('[role="menu"]')
expect(menu.exists()).toBe(true)
expect(screen.getByRole('menu')).toBeInTheDocument()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const menuItems = screen.getAllByRole('menuitemradio')
expect(menuItems).toHaveLength(2)
// Select mode is active (read_only: false), so select is checked
expect(menuItems[0].attributes('aria-checked')).toBe('true')
expect(menuItems[1].attributes('aria-checked')).toBe('false')
expect(menuItems[0]).toHaveAttribute('aria-checked', 'true')
expect(menuItems[1]).toHaveAttribute('aria-checked', 'false')
})
it('should render menu items as buttons with aria-labels', () => {
const wrapper = createWrapper()
renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
menuItems.forEach((btn) => {
expect(btn.element.tagName).toBe('BUTTON')
expect(btn.attributes('type')).toBe('button')
const menuItems = screen.getAllByRole('menuitemradio')
menuItems.forEach((item) => {
expect(item.tagName).toBe('BUTTON')
expect(item).toHaveAttribute('type', 'button')
})
expect(menuItems[0].attributes('aria-label')).toBe('Select')
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
expect(menuItems[0]).toHaveAttribute('aria-label', 'Select')
expect(menuItems[1]).toHaveAttribute('aria-label', 'Hand')
})
it('should use roving tabindex based on active mode', () => {
const wrapper = createWrapper()
renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
// Select is active (read_only: false) → tabindex 0
expect(menuItems[0].attributes('tabindex')).toBe('0')
// Hand is inactive → tabindex -1
expect(menuItems[1].attributes('tabindex')).toBe('-1')
const menuItems = screen.getAllByRole('menuitemradio')
expect(menuItems[0]).toHaveAttribute('tabindex', '0')
expect(menuItems[1]).toHaveAttribute('tabindex', '-1')
})
it('should mark icons as aria-hidden', () => {
const wrapper = createWrapper()
renderComponent()
const icons = wrapper.findAll('[role="menuitemradio"] i')
icons.forEach((icon) => {
expect(icon.attributes('aria-hidden')).toBe('true')
const menuItems = screen.getAllByRole('menuitemradio')
menuItems.forEach((item) => {
// eslint-disable-next-line testing-library/no-node-access
const icons = item.querySelectorAll('i')
icons.forEach((icon) => {
expect(icon).toHaveAttribute('aria-hidden', 'true')
})
})
})
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
const wrapper = createWrapper()
renderComponent()
const trigger = wrapper.find('[aria-haspopup="menu"]')
expect(trigger.exists()).toBe(true)
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
expect(trigger.attributes('aria-expanded')).toBe('false')
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
it('should call focus on next item when ArrowDown is pressed', async () => {
const wrapper = createWrapper()
const { user } = renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const secondItemEl = menuItems[1].element as HTMLElement
const focusSpy = vi.spyOn(secondItemEl, 'focus')
const menuItems = screen.getAllByRole('menuitemradio')
const focusSpy = vi.spyOn(menuItems[1], 'focus')
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
menuItems[0].focus()
await user.keyboard('{ArrowDown}')
expect(focusSpy).toHaveBeenCalled()
})
it('should call focus on previous item when ArrowUp is pressed', async () => {
const wrapper = createWrapper()
const { user } = renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const firstItemEl = menuItems[0].element as HTMLElement
const focusSpy = vi.spyOn(firstItemEl, 'focus')
const menuItems = screen.getAllByRole('menuitemradio')
const focusSpy = vi.spyOn(menuItems[0], 'focus')
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
menuItems[1].focus()
await user.keyboard('{ArrowUp}')
expect(focusSpy).toHaveBeenCalled()
})
it('should close popover on Escape and restore focus to trigger', async () => {
const wrapper = createWrapper()
const { user } = renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const trigger = wrapper.find('[aria-haspopup="menu"]')
const triggerEl = trigger.element as HTMLElement
const focusSpy = vi.spyOn(triggerEl, 'focus')
const menuItems = screen.getAllByRole('menuitemradio')
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
const focusSpy = vi.spyOn(trigger, 'focus')
await menuItems[0].trigger('keydown', { key: 'Escape' })
menuItems[0].focus()
await user.keyboard('{Escape}')
expect(mockPopoverHide).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -89,7 +89,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
mount(DomWidgets, {
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
@@ -134,7 +134,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
mount(DomWidgets, {
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
@@ -160,7 +160,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
mount(DomWidgets, {
render(DomWidgets, {
global: {
stubs: {
DomWidget: true

View File

@@ -1,19 +1,37 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'
// Mock functions
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
const mockGetCommand = vi.fn().mockImplementation((commandId: string) => ({
keybinding: {
combo: {
getKeySequences: () => ['Ctrl', '+']
getKeySequences: () => [
'Ctrl',
commandId === 'Comfy.Canvas.ZoomIn'
? '+'
: commandId === 'Comfy.Canvas.ZoomOut'
? '-'
: '0'
]
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
}))
const mockFormatKeySequence = vi
.fn()
.mockImplementation(
(command: {
keybinding: { combo: { getKeySequences: () => string[] } }
}) => {
const seq = command.keybinding.combo.getKeySequences()
if (seq.includes('+')) return 'Ctrl+'
if (seq.includes('-')) return 'Ctrl-'
return 'Ctrl+0'
}
)
const mockSetAppZoom = vi.fn()
const mockSettingGet = vi.fn().mockReturnValue(true)
@@ -23,11 +41,11 @@ const i18n = createI18n({
messages: { en: {} }
})
// Mock dependencies
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: () => ({
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
containerStyles: {
value: { backgroundColor: '#fff', borderRadius: '8px' }
}
})
}))
@@ -52,8 +70,8 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
const createWrapper = (props = {}) => {
return mount(ZoomControlsModal, {
function renderComponent(props = {}) {
return render(ZoomControlsModal, {
props: {
visible: true,
...props
@@ -70,90 +88,89 @@ const createWrapper = (props = {}) => {
describe('ZoomControlsModal', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.clearAllMocks()
})
it('should execute zoom in command when zoom in button is clicked', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomInButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomIn')
)
expect(zoomInButton).toBeDefined()
await zoomInButton!.trigger('mousedown')
const zoomInButton = screen.getByTestId('zoom-in-action')
await user.click(zoomInButton)
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
})
it('should execute zoom out command when zoom out button is clicked', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomOutButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomOut')
)
expect(zoomOutButton).toBeDefined()
await zoomOutButton!.trigger('mousedown')
const zoomOutButton = screen.getByTestId('zoom-out-action')
await user.click(zoomOutButton)
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
})
it('should execute fit view command when fit view button is clicked', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
expect(fitViewButton).toBeDefined()
await fitViewButton!.trigger('click')
const fitViewButton = screen.getByTestId('zoom-to-fit-action')
await user.click(fitViewButton)
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Emit the input event with PrimeVue's InputNumberInputEvent structure
await inputNumber.vm.$emit('input', { value: 150 })
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('150')
expect(mockSetAppZoom).toHaveBeenCalledWith(150)
})
it('should not call setAppZoomFromPercentage with invalid zoom input values', async () => {
const wrapper = createWrapper()
it('should not call setAppZoomFromPercentage when value is below minimum', async () => {
const user = userEvent.setup()
renderComponent()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('0')
// Test out of range values
await inputNumber.vm.$emit('input', { value: 0 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should not apply zoom values exceeding the maximum', async () => {
const user = userEvent.setup()
renderComponent()
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('100')
mockSetAppZoom.mockClear()
await user.keyboard('1')
await inputNumber.vm.$emit('input', { value: 1001 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should display keyboard shortcuts for commands', () => {
const wrapper = createWrapper()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
expect(buttons.length).toBeGreaterThan(0)
// Each command button should show the keyboard shortcut
expect(mockFormatKeySequence).toHaveBeenCalled()
expect(screen.getByText('Ctrl+')).toBeInTheDocument()
expect(screen.getByText('Ctrl-')).toBeInTheDocument()
expect(screen.getByText('Ctrl+0')).toBeInTheDocument()
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should not be visible when visible prop is false', () => {
const wrapper = createWrapper({ visible: false })
renderComponent({ visible: false })
expect(wrapper.find('.absolute').exists()).toBe(false)
expect(screen.queryByTestId('zoom-in-action')).toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
@@ -105,8 +106,10 @@ describe('ColorPickerButton', () => {
workflowStore.activeWorkflow = createMockWorkflow()
})
const createWrapper = () => {
return mount(ColorPickerButton, {
function renderComponent() {
const user = userEvent.setup()
render(ColorPickerButton, {
global: {
plugins: [PrimeVue, i18n],
directives: {
@@ -114,28 +117,30 @@ describe('ColorPickerButton', () => {
}
}
})
return { user }
}
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('color-picker-button')).toBeInTheDocument()
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
const button = wrapper.find('button')
const { user } = renderComponent()
const button = screen.getByTestId('color-picker-button')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
await button.trigger('click')
const picker = wrapper.findComponent({ name: 'SelectButton' })
expect(picker.exists()).toBe(true)
expect(picker.findAll('button').length).toBeGreaterThan(0)
await user.click(button)
expect(screen.getByTestId('noColor')).toBeInTheDocument()
expect(screen.getByTestId('red')).toBeInTheDocument()
expect(screen.getByTestId('green')).toBeInTheDocument()
expect(screen.getByTestId('blue')).toBeInTheDocument()
await button.trigger('click')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
await user.click(button)
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
@@ -12,7 +13,6 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
}))
@@ -21,7 +21,6 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
}))
// Mock the composables
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: vi.fn(() => ({
selectedNodes: {
@@ -49,14 +48,12 @@ describe('ExecuteButton', () => {
})
beforeEach(() => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Reset mocks
const partialCanvas: Partial<LGraphCanvas> = {
setDirty: vi.fn()
}
@@ -64,14 +61,12 @@ describe('ExecuteButton', () => {
mockSelectedNodes = []
// Get store instances and mock methods
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
vi.mocked(useSelectionState).mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
@@ -81,33 +76,33 @@ describe('ExecuteButton', () => {
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(ExecuteButton, {
const renderComponent = () => {
return render(ExecuteButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:play': { template: '<div class="play-icon" />' }
}
directives: { tooltip: Tooltip }
}
})
}
describe('Rendering', () => {
it('should be able to render', () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
renderComponent()
expect(
screen.getByRole('button', { name: 'Execute selected nodes' })
).toBeTruthy()
})
})
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const commandStore = useCommandStore()
const wrapper = mountComponent()
const button = wrapper.find('button')
const user = userEvent.setup()
renderComponent()
await button.trigger('click')
await user.click(
screen.getByRole('button', { name: 'Execute selected nodes' })
)
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
@@ -18,6 +19,12 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: vi.fn()
})
}))
describe('InfoButton', () => {
const i18n = createI18n({
legacy: false,
@@ -36,8 +43,8 @@ describe('InfoButton', () => {
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(InfoButton, {
const renderComponent = () => {
return render(InfoButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
@@ -47,9 +54,11 @@ describe('InfoButton', () => {
}
it('should open the info panel on click', async () => {
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="info-button"]')
await button.trigger('click')
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -1,9 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { nextTick, reactive } from 'vue'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
@@ -100,16 +100,17 @@ describe('DomWidget disabled style', () => {
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
const { container } = render(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
await nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const root = container.querySelector('.dom-widget') as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})

View File

@@ -1,5 +1,5 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
@@ -11,10 +11,11 @@ describe('HoneyToast', () => {
document.body.innerHTML = ''
})
function mountComponent(
function renderComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
) {
const user = userEvent.setup()
const { unmount } = render(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
@@ -33,48 +34,45 @@ describe('HoneyToast', () => {
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
return { user, unmount }
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
const { unmount } = renderComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
expect(screen.getByRole('status')).toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
const { unmount } = renderComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
const { unmount } = renderComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
wrapper.unmount()
unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
const { unmount } = renderComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
wrapper.unmount()
unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
@@ -98,23 +96,21 @@ describe('HoneyToast', () => {
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
const user = userEvent.setup()
const { unmount } = render(TestWrapper, {
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Expand')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await user.click(screen.getByTestId('toggle-btn'))
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
expect(screen.getByTestId('content')).toHaveTextContent('expanded')
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Collapse')
wrapper.unmount()
unmount()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -27,7 +28,7 @@ const options = [
{ name: 'Option C', value: 'c' }
]
function mountInParent(
function renderInParent(
multiSelectProps: Record<string, unknown> = {},
modelValue: { name: string; value: string }[] = []
) {
@@ -49,12 +50,12 @@ function mountInParent(
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
const { unmount } = render(Parent, {
container: document.body.appendChild(document.createElement('div')),
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
return { unmount, parentEscapeCount }
}
function dispatchEscape(element: Element) {
@@ -73,30 +74,32 @@ function findContentElement(): HTMLElement | null {
describe('MultiSelect', () => {
it('keeps open-state border styling available while the dropdown is open', async () => {
const { wrapper } = mountInParent()
const user = userEvent.setup()
const { unmount } = renderInParent()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
const trigger = screen.getByRole('button')
expect(trigger.classes()).toContain(
expect(trigger).toHaveClass(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
await trigger.trigger('click')
await user.click(trigger)
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(trigger).toHaveAttribute('data-state', 'open')
wrapper.unmount()
unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const user = userEvent.setup()
const { unmount, parentEscapeCount } = renderInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
const trigger = screen.getByRole('button')
await user.click(trigger)
await nextTick()
const content = findContentElement()
@@ -107,48 +110,46 @@ describe('MultiSelect', () => {
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const user = userEvent.setup()
const { unmount } = renderInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
const trigger = screen.getByRole('button')
await user.click(trigger)
await nextTick()
expect(trigger.attributes('data-state')).toBe('open')
expect(trigger).toHaveAttribute('data-state', 'open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
expect(trigger).toHaveAttribute('data-state', 'closed')
wrapper.unmount()
unmount()
})
})
describe('selected count badge', () => {
it('shows selected count when items are selected', () => {
const { wrapper } = mountInParent({}, [
const { unmount } = renderInParent({}, [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' }
])
expect(wrapper.text()).toContain('2')
expect(screen.getByText('2')).toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('does not show count badge when no items are selected', () => {
const { wrapper } = mountInParent()
const multiSelect = wrapper.findComponent(MultiSelect)
const spans = multiSelect.findAll('span')
const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim()))
const { unmount } = renderInParent()
expect(countBadge).toBeUndefined()
expect(screen.queryByText(/^\d+$/)).not.toBeInTheDocument()
wrapper.unmount()
unmount()
})
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -37,7 +37,7 @@ function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
function mountInParent(modelValue?: string) {
function renderInParent(modelValue?: string) {
const parentEscapeCount = { value: 0 }
const Parent = {
@@ -55,12 +55,12 @@ function mountInParent(modelValue?: string) {
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
const { unmount } = render(Parent, {
container: document.body.appendChild(document.createElement('div')),
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
return { unmount, parentEscapeCount }
}
async function openSelect(triggerEl: HTMLElement) {
@@ -81,10 +81,10 @@ async function openSelect(triggerEl: HTMLElement) {
describe('SingleSelect', () => {
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const { unmount, parentEscapeCount } = renderInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
const trigger = screen.getByRole('combobox')
await openSelect(trigger)
const content = findContentElement()
expect(content).not.toBeNull()
@@ -94,23 +94,23 @@ describe('SingleSelect', () => {
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const { unmount } = renderInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
expect(trigger.attributes('data-state')).toBe('open')
const trigger = screen.getByRole('combobox')
await openSelect(trigger)
expect(trigger).toHaveAttribute('data-state', 'open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
expect(trigger).toHaveAttribute('data-state', 'closed')
wrapper.unmount()
unmount()
})
})
})

View File

@@ -1,10 +1,11 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import * as markdownRendererUtil from '@/utils/markdownRendererUtil'
@@ -54,13 +55,11 @@ describe('NodePreview', () => {
description: 'Test node description'
}
const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
return mount(NodePreview, {
function renderComponent(nodeDef: ComfyNodeDefV2 = mockNodeDef) {
return render(NodePreview, {
global: {
plugins: [PrimeVue, i18n, pinia],
stubs: {
// Stub stores if needed
}
stubs: {}
},
props: {
nodeDef
@@ -69,18 +68,18 @@ describe('NodePreview', () => {
}
it('renders node preview with correct structure', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
expect(wrapper.find('.node_header').exists()).toBe(true)
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
expect(screen.getByTestId('node-header')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument()
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
renderComponent()
const nodeHeader = screen.getByTestId('node-header')
expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
expect(nodeHeader).toHaveAttribute('title', mockNodeDef.display_name)
})
it('displays truncated long node names with ellipsis', () => {
@@ -90,17 +89,11 @@ describe('NodePreview', () => {
'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
}
const wrapper = mountComponent(longNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
renderComponent(longNameNodeDef)
const nodeHeader = screen.getByTestId('node-header')
// Verify the title attribute contains the full name
expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
// Verify overflow handling classes are applied
expect(nodeHeader.classes()).toContain('text-ellipsis')
// The actual text content should still be the full name (CSS handles truncation)
expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
expect(nodeHeader).toHaveAttribute('title', longNameNodeDef.display_name)
expect(nodeHeader).toHaveTextContent(longNameNodeDef.display_name!)
})
it('handles short node names without issues', () => {
@@ -109,18 +102,18 @@ describe('NodePreview', () => {
display_name: 'Short'
}
const wrapper = mountComponent(shortNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
renderComponent(shortNameNodeDef)
const nodeHeader = screen.getByTestId('node-header')
expect(nodeHeader.attributes('title')).toBe('Short')
expect(nodeHeader.text()).toContain('Short')
expect(nodeHeader).toHaveAttribute('title', 'Short')
expect(nodeHeader).toHaveTextContent('Short')
})
it('applies proper spacing to the dot element', () => {
const wrapper = mountComponent()
const headdot = wrapper.find('.headdot')
renderComponent()
const headdot = screen.getByTestId('head-dot')
expect(headdot.classes()).toContain('pr-3')
expect(headdot).toBeInTheDocument()
})
describe('Description Rendering', () => {
@@ -130,11 +123,13 @@ describe('NodePreview', () => {
description: 'This is a plain text description'
}
const wrapper = mountComponent(plainTextNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(plainTextNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('This is a plain text description')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain(
'This is a plain text description'
)
})
it('renders markdown description with formatting', () => {
@@ -143,13 +138,13 @@ describe('NodePreview', () => {
description: '**Bold text** and *italic text* with `code`'
}
const wrapper = mountComponent(markdownNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(markdownNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('<strong>Bold text</strong>')
expect(description.html()).toContain('<em>italic text</em>')
expect(description.html()).toContain('<code>code</code>')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<strong>Bold text</strong>')
expect(description.innerHTML).toContain('<em>italic text</em>')
expect(description.innerHTML).toContain('<code>code</code>')
})
it('does not render description element when description is empty', () => {
@@ -158,20 +153,16 @@ describe('NodePreview', () => {
description: ''
}
const wrapper = mountComponent(noDescriptionNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(noDescriptionNodeDef)
expect(description.exists()).toBe(false)
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
})
it('does not render description element when description is undefined', () => {
const { description, ...nodeDefWithoutDescription } = mockNodeDef
const wrapper = mountComponent(
nodeDefWithoutDescription as ComfyNodeDefV2
)
const descriptionElement = wrapper.find('._sb_description')
renderComponent(nodeDefWithoutDescription as ComfyNodeDefV2)
expect(descriptionElement.exists()).toBe(false)
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
})
it('calls renderMarkdownToHtml utility function', () => {
@@ -183,7 +174,7 @@ describe('NodePreview', () => {
description: testDescription
}
mountComponent(nodeDefWithDescription)
renderComponent(nodeDefWithDescription)
expect(spy).toHaveBeenCalledWith(testDescription)
spy.mockRestore()
@@ -196,21 +187,13 @@ describe('NodePreview', () => {
'Safe **markdown** content <script>alert("xss")</script> with `code` blocks'
}
const wrapper = mountComponent(unsafeNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(unsafeNodeDef)
const description = screen.getByTestId('node-description')
// The description should still exist because there's safe content
if (description.exists()) {
// Should not contain script tags (sanitized by DOMPurify)
expect(description.html()).not.toContain('<script>')
expect(description.html()).not.toContain('alert("xss")')
// Should contain the safe markdown content rendered as HTML
expect(description.html()).toContain('<strong>markdown</strong>')
expect(description.html()).toContain('<code>code</code>')
} else {
// If DOMPurify removes everything, that's also acceptable for security
expect(description.exists()).toBe(false)
}
expect(description.innerHTML).not.toContain('<script>')
expect(description.innerHTML).not.toContain('alert("xss")')
expect(description.innerHTML).toContain('<strong>markdown</strong>')
expect(description.innerHTML).toContain('<code>code</code>')
})
it('handles markdown with line breaks', () => {
@@ -219,12 +202,11 @@ describe('NodePreview', () => {
description: 'Line 1\n\nLine 3 after empty line'
}
const wrapper = mountComponent(multilineNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(multilineNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
// Should contain paragraph tags for proper line break handling
expect(description.html()).toContain('<p>')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<p>')
})
it('handles markdown lists', () => {
@@ -233,19 +215,19 @@ describe('NodePreview', () => {
description: '- Item 1\n- Item 2\n- Item 3'
}
const wrapper = mountComponent(listNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(listNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('<ul>')
expect(description.html()).toContain('<li>')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<ul>')
expect(description.innerHTML).toContain('<li>')
})
it('applies correct styling classes to description', () => {
const wrapper = mountComponent()
const description = wrapper.find('._sb_description')
it('renders description element', () => {
renderComponent()
const description = screen.getByTestId('node-description')
expect(description.classes()).toContain('_sb_description')
expect(description).toBeInTheDocument()
})
it('uses v-html directive for rendered content', () => {
@@ -254,12 +236,11 @@ describe('NodePreview', () => {
description: 'Content with **bold** text'
}
const wrapper = mountComponent(htmlNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(htmlNodeDef)
const description = screen.getByTestId('node-description')
// The component should render the HTML, not escape it
expect(description.html()).toContain('<strong>bold</strong>')
expect(description.html()).not.toContain('&lt;strong&gt;')
expect(description.innerHTML).toContain('<strong>bold</strong>')
expect(description.innerHTML).not.toContain('&lt;strong&gt;')
})
it('prevents XSS attacks by sanitizing dangerous HTML elements', () => {
@@ -269,17 +250,12 @@ describe('NodePreview', () => {
'Normal text <img src="x" onerror="alert(\'XSS\')" /> and **bold** text'
}
const wrapper = mountComponent(maliciousNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(maliciousNodeDef)
const description = screen.getByTestId('node-description')
if (description.exists()) {
// Should not contain dangerous event handlers
expect(description.html()).not.toContain('onerror')
expect(description.html()).not.toContain('alert(')
// Should still contain safe markdown content
expect(description.html()).toContain('<strong>bold</strong>')
// May or may not contain img tag depending on DOMPurify config
}
expect(description.innerHTML).not.toContain('onerror')
expect(description.innerHTML).not.toContain('alert(')
expect(description.innerHTML).toContain('<strong>bold</strong>')
})
})
})

View File

@@ -7,17 +7,22 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
:node-def="nodeDef"
:position="position"
/>
<div v-else class="_sb_node_preview bg-component-node-background">
<div
v-else
class="_sb_node_preview bg-component-node-background"
data-testid="node-preview"
>
<div class="_sb_table">
<div
class="node_header text-ellipsis"
data-testid="node-header"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot pr-3" />
<div class="_sb_dot headdot pr-3" data-testid="head-dot" />
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>
@@ -76,6 +81,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div
v-if="renderedDescription"
class="_sb_description"
data-testid="node-description"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR,
backgroundColor: litegraphColors.WIDGET_BGCOLOR

View File

@@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const popoverCloseSpy = vi.fn()
@@ -52,8 +52,10 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const renderMenu = () =>
render(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -75,12 +77,11 @@ describe('JobHistoryActionsMenu', () => {
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountMenu()
const user = userEvent.setup()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
renderMenu()
await user.click(screen.getByTestId('show-run-progress-bar-action'))
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
@@ -90,17 +91,16 @@ describe('JobHistoryActionsMenu', () => {
})
it('opens docked job history sidebar when enabling from the menu', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) => {
if (key === 'Comfy.Queue.QPOV2') return false
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
return undefined
})
const wrapper = mountMenu()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
renderMenu()
await user.click(screen.getByTestId('docked-job-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
@@ -110,14 +110,20 @@ describe('JobHistoryActionsMenu', () => {
})
it('emits clear history from the menu', async () => {
const wrapper = mountMenu()
const user = userEvent.setup()
const clearHistorySpy = vi.fn()
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
render(JobHistoryActionsMenu, {
props: { onClearHistory: clearHistorySpy },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByTestId('clear-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
expect(clearHistorySpy).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -32,30 +33,20 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
summaryRow: '.flex.items-center.gap-2',
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
const defaultProps = {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row'
}
const COPY = {
viewAllJobs: 'View all jobs'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row',
...props
},
const renderComponent = (props: Record<string, unknown> = {}) =>
render(QueueOverlayActive, {
props: { ...defaultProps, ...props },
global: {
plugins: [i18n],
directives: {
@@ -66,58 +57,65 @@ const mountComponent = (props: Record<string, unknown> = {}) =>
describe('QueueOverlayActive', () => {
it('renders progress metrics and emits actions when buttons clicked', async () => {
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
const user = userEvent.setup()
const interruptAllSpy = vi.fn()
const clearQueuedSpy = vi.fn()
const viewAllJobsSpy = vi.fn()
const progressBars = wrapper.findAll('.absolute.inset-0')
expect(progressBars[0].attributes('style')).toContain('width: 65%')
expect(progressBars[1].attributes('style')).toContain('width: 40%')
const { container } = renderComponent({
runningCount: 2,
queuedCount: 3,
onInterruptAll: interruptAllSpy,
onClearQueued: clearQueuedSpy,
onViewAllJobs: viewAllJobsSpy
})
const content = wrapper.text().replace(/\s+/g, ' ')
expect(content).toContain('Total: 65%')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const progressBars = container.querySelectorAll('.absolute.inset-0')
expect(progressBars[0]).toHaveStyle({ width: '65%' })
expect(progressBars[1]).toHaveStyle({ width: '40%' })
const [runningSection, queuedSection] = wrapper.findAll(
SELECTORS.summaryRow
expect(screen.getByText('65%')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('running')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('queued')).toBeInTheDocument()
expect(screen.getByText('Current node:')).toBeInTheDocument()
expect(screen.getByText('Sampler')).toBeInTheDocument()
expect(screen.getByText('40%')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Interrupt all running jobs' })
)
expect(runningSection.text()).toContain('2')
expect(runningSection.text()).toContain('running')
expect(queuedSection.text()).toContain('3')
expect(queuedSection.text()).toContain('queued')
expect(interruptAllSpy).toHaveBeenCalledOnce()
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
expect(currentNodeSection.text()).toContain('Current node:')
expect(currentNodeSection.text()).toContain('Sampler')
expect(currentNodeSection.text()).toContain('40%')
await user.click(screen.getByRole('button', { name: 'Clear queued' }))
expect(clearQueuedSpy).toHaveBeenCalledOnce()
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
await interruptButton.trigger('click')
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'View all jobs' }))
expect(viewAllJobsSpy).toHaveBeenCalledOnce()
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
const buttons = wrapper.findAll('button')
const viewAllButton = buttons.find((btn) =>
btn.text().includes(COPY.viewAllJobs)
)
expect(viewAllButton).toBeDefined()
await viewAllButton!.trigger('click')
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.custom-bottom-row')).toBeTruthy()
})
it('hides action buttons when counts are zero', () => {
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
renderComponent({ runningCount: 0, queuedCount: 0 })
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
expect(
screen.queryByRole('button', { name: 'Interrupt all running jobs' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear queued' })
).not.toBeInTheDocument()
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
renderComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
@@ -55,8 +56,8 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
const renderHeader = (props = {}) =>
render(QueueOverlayHeader, {
props: {
headerTitle: 'Job queue',
queuedCount: 3,
@@ -81,54 +82,53 @@ describe('QueueOverlayHeader', () => {
})
it('renders header title', () => {
const wrapper = mountHeader()
expect(wrapper.text()).toContain('Job queue')
renderHeader()
expect(screen.getByText('Job queue')).toBeInTheDocument()
})
it('shows clear queue text and emits clear queued', async () => {
const wrapper = mountHeader({ queuedCount: 4 })
const user = userEvent.setup()
const clearQueuedSpy = vi.fn()
expect(wrapper.text()).toContain('Clear queue')
expect(wrapper.text()).not.toContain('4 queued')
renderHeader({ queuedCount: 4, onClearQueued: clearQueuedSpy })
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
expect(screen.getByText('Clear queue')).toBeInTheDocument()
expect(screen.queryByText('4 queued')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Clear queued' }))
expect(clearQueuedSpy).toHaveBeenCalledOnce()
})
it('disables clear queued button when queued count is zero', () => {
const wrapper = mountHeader({ queuedCount: 0 })
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
renderHeader({ queuedCount: 0 })
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('Clear queue')
expect(screen.getByRole('button', { name: 'Clear queued' })).toBeDisabled()
expect(screen.getByText('Clear queue')).toBeInTheDocument()
})
it('emits clear history from the menu', async () => {
const user = userEvent.setup()
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const clearHistorySpy = vi.fn()
const wrapper = mountHeader()
renderHeader({ onClearHistory: clearHistorySpy })
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
expect(
screen.getByRole('button', { name: 'More options' })
).toBeInTheDocument()
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
await user.click(screen.getByTestId('clear-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
expect(clearHistorySpy).toHaveBeenCalledOnce()
})
it('opens floating queue progress overlay when disabling from the menu', async () => {
const wrapper = mountHeader()
const user = userEvent.setup()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledTimes(1)
@@ -141,15 +141,14 @@ describe('QueueOverlayHeader', () => {
})
it('opens docked job history sidebar when enabling from the menu', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
@@ -159,16 +158,15 @@ describe('QueueOverlayHeader', () => {
})
it('keeps docked target open even when enabling persistence fails', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
@@ -176,13 +174,12 @@ describe('QueueOverlayHeader', () => {
})
it('closes the menu when disabling persistence fails', async () => {
const user = userEvent.setup()
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
@@ -192,12 +189,11 @@ describe('QueueOverlayHeader', () => {
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountHeader()
const user = userEvent.setup()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
renderHeader()
await user.click(screen.getByTestId('show-run-progress-bar-action'))
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { defineComponent, nextTick, ref } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
@@ -28,7 +29,6 @@ const popoverStub = defineComponent({
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
@@ -43,7 +43,7 @@ const popoverStub = defineComponent({
}
},
template: `
<div v-if="visible" ref="container" class="popover-stub">
<div v-if="visible" ref="container" data-testid="popover">
<slot />
</div>
`
@@ -51,21 +51,18 @@ const popoverStub = defineComponent({
const buttonStub = {
props: {
disabled: {
type: Boolean,
default: false
}
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: undefined }
},
template: `
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<button :disabled="disabled" :aria-label="ariaLabel">
<slot />
</div>
</button>
`
}
type MenuHandle = { open: (e: Event) => Promise<void>; hide: () => void }
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
@@ -77,17 +74,6 @@ const createEntries = (): MenuEntry[] => [
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
@@ -95,13 +81,37 @@ const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
target: currentTarget
}) as Event
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
function renderMenu(entries: MenuEntry[], onAction?: ReturnType<typeof vi.fn>) {
const menuRef = ref<MenuHandle | null>(null)
const Wrapper = {
components: { JobContextMenu },
setup() {
return { menuRef, entries }
},
template:
'<JobContextMenu ref="menuRef" :entries="entries" @action="$emit(\'action\', $event)" />'
}
const user = userEvent.setup()
const actionSpy = onAction ?? vi.fn()
const { unmount } = render(Wrapper, {
props: { onAction: actionSpy },
global: {
stubs: { Popover: popoverStub, Button: buttonStub }
}
})
return { user, menuRef, onAction: actionSpy, unmount }
}
async function openMenu(
menuRef: ReturnType<typeof ref<MenuHandle | null>>,
type: string = 'click'
) => {
) {
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent(type, trigger))
await menuRef.value!.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
@@ -112,31 +122,33 @@ afterEach(() => {
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', async () => {
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const { menuRef, unmount } = renderMenu(createEntries())
await openMenu(menuRef)
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
const enabledBtn = screen.getByRole('button', { name: 'Enabled action' })
const disabledBtn = screen.getByRole('button', {
name: 'Disabled action'
})
expect(enabledBtn).not.toBeDisabled()
expect(disabledBtn).toBeDisabled()
wrapper.unmount()
unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await openMenu(wrapper)
const { user, menuRef, onAction, unmount } = renderMenu(entries)
await openMenu(menuRef)
await wrapper.findAll('.button-stub')[0].trigger('click')
await user.click(screen.getByRole('button', { name: 'Enabled action' }))
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
expect(onAction).toHaveBeenCalledWith(entries[0])
wrapper.unmount()
unmount()
})
it('does not emit action for disabled entries', async () => {
const wrapper = mountComponent([
const { user, menuRef, onAction, unmount } = renderMenu([
{
key: 'disabled',
label: 'Disabled action',
@@ -144,52 +156,54 @@ describe('JobContextMenu', () => {
onClick: vi.fn()
}
])
await openMenu(wrapper)
await openMenu(menuRef)
await wrapper.get('.button-stub').trigger('click')
await user.click(screen.getByRole('button', { name: 'Disabled action' }))
expect(wrapper.emitted('action')).toBeUndefined()
expect(onAction).not.toHaveBeenCalled()
wrapper.unmount()
unmount()
})
it('hides on pointerdown outside the popover', async () => {
const wrapper = mountComponent(createEntries())
const { menuRef, unmount } = renderMenu(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await menuRef.value!.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
expect(screen.getByTestId('popover')).toBeInTheDocument()
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.pointerDown(outside)
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
expect(screen.queryByTestId('popover')).not.toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const wrapper = mountComponent(createEntries())
const { menuRef, unmount } = renderMenu(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await menuRef.value!.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
expect(screen.getByTestId('popover')).toBeInTheDocument()
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.pointerDown(trigger)
await nextTick()
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await menuRef.value!.open(createTriggerEvent('click', trigger))
await nextTick()
expect(screen.queryByTestId('popover')).not.toBeInTheDocument()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
unmount()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
@@ -56,12 +57,16 @@ const i18n = createI18n({
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
const user = userEvent.setup()
const showAssetsSpy = vi.fn()
render(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false
hasFailedJobs: false,
onShowAssets: showAssetsSpy
},
global: {
plugins: [i18n],
@@ -69,16 +74,13 @@ describe('JobFiltersBar', () => {
}
})
const showAssetsButton = wrapper.get(
'button[aria-label="Show assets panel"]'
)
await showAssetsButton.trigger('click')
await user.click(screen.getByRole('button', { name: 'Show assets panel' }))
expect(wrapper.emitted('showAssets')).toHaveLength(1)
expect(showAssetsSpy).toHaveBeenCalledOnce()
})
it('hides the assets icon button when hideShowAssetsAction is true', () => {
const wrapper = mount(JobFiltersBar, {
render(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
@@ -93,7 +95,7 @@ describe('JobFiltersBar', () => {
})
expect(
wrapper.find('button[aria-label="Show assets panel"]').exists()
).toBe(false)
screen.queryByRole('button', { name: 'Show assets panel' })
).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
@@ -23,7 +23,12 @@ const QueueJobItemStub = defineComponent({
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
template: '<div class="queue-job-item-stub"></div>'
template: `
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
</div>
`
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
@@ -46,8 +51,16 @@ const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
}
}
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {
function getActiveDetailsId(container: Element, jobId: string): string | null {
return (
container
.querySelector(`[data-job-id="${jobId}"]`)
?.getAttribute('data-active-details-id') ?? null
)
}
const renderComponent = (groups: JobGroup[]) =>
render(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
@@ -64,64 +77,60 @@ describe('JobGroupsList hover behavior', () => {
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const wrapper = mountComponent([
const { container } = renderComponent([
{ key: 'today', label: 'Today', items: [job] }
])
const jobItem = wrapper.findComponent(QueueJobItemStub)
jobItem.vm.$emit('details-enter', job.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-d'))
vi.advanceTimersByTime(199)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-d'))
vi.advanceTimersByTime(149)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const wrapper = mountComponent([
const { container } = renderComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
jobItems[0].vm.$emit('details-enter', firstJob.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-1'))
vi.advanceTimersByTime(200)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
jobItems[0].vm.$emit('details-leave', firstJob.id)
jobItems[1].vm.$emit('details-enter', secondJob.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-1'))
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-2'))
vi.advanceTimersByTime(100)
await nextTick()
jobItems[1].vm.$emit('details-leave', secondJob.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-2'))
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBeNull()
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[1].props('activeDetailsId')).toBeNull()
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -89,16 +90,21 @@ describe('ErrorNodeCard.vue', () => {
})
})
function mountCard(card: ErrorCardData) {
return mount(ErrorNodeCard, {
props: { card },
function renderCard(
card: ErrorCardData,
options: { initialState?: Record<string, unknown> } = {}
) {
const user = userEvent.setup()
const onCopyToClipboard = vi.fn()
render(ErrorNodeCard, {
props: { card, onCopyToClipboard },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: {
initialState: options.initialState ?? {
systemStats: {
systemStats: {
system: {
@@ -132,6 +138,7 @@ describe('ErrorNodeCard.vue', () => {
}
}
})
return { user, onCopyToClipboard }
}
let cardIdCounter = 0
@@ -173,76 +180,82 @@ describe('ErrorNodeCard.vue', () => {
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
renderCard(makeRuntimeErrorCard())
expect(wrapper.text()).toContain('ComfyUI Error Report')
expect(wrapper.text()).toContain('System Information')
expect(wrapper.text()).toContain('OS: Linux')
await waitFor(() => {
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
})
expect(screen.getByText(/System Information/)).toBeInTheDocument()
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
})
it('does not generate report for non-runtime errors', async () => {
mountCard(makeValidationErrorCard())
await flushPromises()
renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
expect(mockGetLogs).not.toHaveBeenCalled()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})
it('displays original details for non-runtime errors', async () => {
const wrapper = mountCard(makeValidationErrorCard())
await flushPromises()
renderCard(makeValidationErrorCard())
expect(wrapper.text()).toContain('Input: text')
expect(wrapper.text()).not.toContain('ComfyUI Error Report')
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('copies enriched report when copy button is clicked for runtime error', async () => {
const reportText = '# Full Report Content'
mockGenerateErrorReport.mockReturnValue(reportText)
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))!
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
await waitFor(() => {
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
})
const emitted = wrapper.emitted('copyToClipboard')
expect(emitted).toHaveLength(1)
expect(emitted![0][0]).toContain('# Full Report Content')
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toContain(
'# Full Report Content'
)
})
it('copies original details when copy button is clicked for validation error', async () => {
const wrapper = mountCard(makeValidationErrorCard())
await flushPromises()
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))!
await copyButton.trigger('click')
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
const emitted = wrapper.emitted('copyToClipboard')
expect(emitted).toHaveLength(1)
expect(emitted![0][0]).toBe('Required input is missing\n\nInput: text')
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
'Required input is missing\n\nInput: text'
)
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
renderCard(makeRuntimeErrorCard())
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
})
it('falls back to original details when generateErrorReport throws', async () => {
@@ -250,24 +263,25 @@ describe('ErrorNodeCard.vue', () => {
throw new Error('Serialization error')
})
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
renderCard(makeRuntimeErrorCard())
expect(wrapper.text()).toContain('Traceback line 1')
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
const { user } = renderCard(makeRuntimeErrorCard())
const findIssuesButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Find on GitHub'))!
expect(findIssuesButton.exists()).toBe(true)
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
})
await findIssuesButton.trigger('click')
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
@@ -284,15 +298,15 @@ describe('ErrorNodeCard.vue', () => {
})
it('executes ContactSupport command when Get Help button is clicked', async () => {
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
const { user } = renderCard(makeRuntimeErrorCard())
const getHelpButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Get Help'))!
expect(getHelpButton.exists()).toBe(true)
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Get Help/ })
).toBeInTheDocument()
})
await getHelpButton.trigger('click')
await user.click(screen.getByRole('button', { name: /Get Help/ }))
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
@@ -304,9 +318,11 @@ describe('ErrorNodeCard.vue', () => {
})
it('passes exceptionType from error item to report generator', async () => {
mountCard(makeRuntimeErrorCard())
await flushPromises()
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'RuntimeError'
@@ -329,9 +345,11 @@ describe('ErrorNodeCard.vue', () => {
]
}
mountCard(card)
await flushPromises()
renderCard(card)
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'Runtime Error'
@@ -340,30 +358,16 @@ describe('ErrorNodeCard.vue', () => {
})
it('falls back to original details when systemStats is unavailable', async () => {
const wrapper = mount(ErrorNodeCard, {
props: { card: makeRuntimeErrorCard() },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: {
systemStats: { systemStats: null }
}
})
],
stubs: {
Button: {
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
}
}
renderCard(makeRuntimeErrorCard(), {
initialState: {
systemStats: { systemStats: null }
}
})
await flushPromises()
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Traceback line 1')
})
})

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -68,7 +69,13 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: '<div class="pack-row" />',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
@@ -95,7 +102,8 @@ const i18n = createI18n({
'Some nodes require a newer version of ComfyUI (current: {version}).',
outdatedVersionGeneric:
'Some nodes require a newer version of ComfyUI.',
coreNodesFromVersion: 'Requires ComfyUI {version}:'
coreNodesFromVersion: 'Requires ComfyUI {version}:',
unknownVersion: 'unknown'
}
}
},
@@ -113,14 +121,15 @@ function makePackGroups(count = 2): MissingPackGroup[] {
}))
}
function mountCard(
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
return mount(MissingNodeCard, {
const user = userEvent.setup()
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
@@ -134,6 +143,7 @@ function mountCard(
}
}
})
return { ...result, user }
}
describe('MissingNodeCard', () => {
@@ -151,131 +161,163 @@ describe('MissingNodeCard', () => {
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
const wrapper = mountCard()
expect(wrapper.text()).toContain('Unsupported node packs detected')
renderCard()
expect(
screen.getByText('Unsupported node packs detected.')
).toBeInTheDocument()
})
it('renders OSS message when isCloud is false', () => {
const wrapper = mountCard()
expect(wrapper.text()).toContain('Missing node packs detected')
renderCard()
expect(
screen.getByText('Missing node packs detected. Install them.')
).toBeInTheDocument()
})
it('renders correct number of MissingPackGroupRow components', () => {
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(3)
renderCard({ missingPackGroups: makePackGroups(3) })
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
const wrapper = mountCard({ missingPackGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(0)
renderCard({ missingPackGroups: [] })
expect(screen.queryAllByTestId('pack-row')).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
const wrapper = mountCard({
renderCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
expect(row.props('showInfoButton')).toBe(true)
expect(row.props('showNodeIdBadge')).toBe(true)
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
describe('Manager Disabled Hint', () => {
it('shows hint when OSS and manager is disabled (showInfoButton false)', () => {
mockIsCloud.value = false
const wrapper = mountCard({ showInfoButton: false })
expect(wrapper.text()).toContain('pip install -U --pre comfyui-manager')
expect(wrapper.text()).toContain('--enable-manager')
renderCard({ showInfoButton: false })
expect(
screen.getByText('pip install -U --pre comfyui-manager')
).toBeInTheDocument()
expect(screen.getByText('--enable-manager')).toBeInTheDocument()
})
it('hides hint when manager is enabled (showInfoButton true)', () => {
mockIsCloud.value = false
const wrapper = mountCard({ showInfoButton: true })
expect(wrapper.text()).not.toContain('--enable-manager')
renderCard({ showInfoButton: true })
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
})
it('hides hint on Cloud even when showInfoButton is false', () => {
mockIsCloud.value = true
const wrapper = mountCard({ showInfoButton: false })
expect(wrapper.text()).not.toContain('--enable-manager')
renderCard({ showInfoButton: false })
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
renderCard()
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
renderCard()
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
expect(wrapper.text()).toContain('Apply Changes')
renderCard()
expect(screen.getByText('Apply Changes')).toBeInTheDocument()
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
renderCard()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
renderCard()
expect(
screen.getByRole('button', { name: /apply changes/i })
).toBeDisabled()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
const btn = wrapper.find('button')
await btn.trigger('click')
const { user } = renderCard()
await user.click(screen.getByRole('button', { name: /apply changes/i }))
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
const onLocateNode = vi.fn()
const user = userEvent.setup()
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
await user.click(screen.getAllByTestId('locate-node')[0])
expect(onLocateNode).toHaveBeenCalledWith('0')
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('open-manager-info', 'pack-0')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
const onOpenManagerInfo = vi.fn()
const user = userEvent.setup()
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
await user.click(screen.getAllByTestId('open-manager-info')[0])
expect(onOpenManagerInfo).toHaveBeenCalledWith('pack-0')
})
})
describe('Core Node Version Warning', () => {
it('does not render warning when no missing core nodes', () => {
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
const { container } = renderCard()
expect(container.textContent).not.toContain('newer version of ComfyUI')
})
it('renders warning with version when missing core nodes exist', () => {
@@ -283,20 +325,20 @@ describe('MissingNodeCard', () => {
'1.2.0': [{ type: 'TestNode' }]
}
mockSystemStats.value = { system: { comfyui_version: '1.0.0' } }
const wrapper = mountCard()
expect(wrapper.text()).toContain('(current: 1.0.0)')
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
expect(wrapper.text()).toContain('TestNode')
const { container } = renderCard()
expect(container.textContent).toContain('(current: 1.0.0)')
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
expect(container.textContent).toContain('TestNode')
})
it('renders generic message when version is unavailable', () => {
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
const wrapper = mountCard()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI.'
)
renderCard()
expect(
screen.getByText('Some nodes require a newer version of ComfyUI.')
).toBeInTheDocument()
})
it('does not render warning on Cloud', () => {
@@ -304,8 +346,8 @@ describe('MissingNodeCard', () => {
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
const { container } = renderCard()
expect(container.textContent).not.toContain('newer version of ComfyUI')
})
it('deduplicates and sorts node names within a version', () => {
@@ -316,9 +358,10 @@ describe('MissingNodeCard', () => {
{ type: 'ZebraNode' }
]
}
const wrapper = mountCard()
expect(wrapper.text()).toContain('AlphaNode, ZebraNode')
expect(wrapper.text().match(/ZebraNode/g)?.length).toBe(1)
const { container } = renderCard()
expect(container.textContent).toContain('AlphaNode, ZebraNode')
// eslint-disable-next-line testing-library/no-container
expect(container.textContent?.match(/ZebraNode/g)).toHaveLength(1)
})
it('sorts versions in descending order', () => {
@@ -327,8 +370,8 @@ describe('MissingNodeCard', () => {
'1.3.0': [{ type: 'Node3' }],
'1.2.0': [{ type: 'Node2' }]
}
const wrapper = mountCard()
const text = wrapper.text()
const { container } = renderCard()
const text = container.textContent ?? ''
const v13 = text.indexOf('1.3.0')
const v12 = text.indexOf('1.2.0')
const v11 = text.indexOf('1.1.0')
@@ -341,11 +384,11 @@ describe('MissingNodeCard', () => {
'': [{ type: 'NoVersionNode' }],
'1.2.0': [{ type: 'VersionedNode' }]
}
const wrapper = mountCard()
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
expect(wrapper.text()).toContain('VersionedNode')
expect(wrapper.text()).toContain('unknown')
expect(wrapper.text()).toContain('NoVersionNode')
const { container } = renderCard()
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
expect(container.textContent).toContain('VersionedNode')
expect(container.textContent).toContain('unknown')
expect(container.textContent).toContain('NoVersionNode')
})
})
})

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -95,18 +96,23 @@ function makeGroup(
}
}
function mountRow(
function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingPackGroupRow, {
const user = userEvent.setup()
const onLocateNode = vi.fn()
const onOpenManagerInfo = vi.fn()
render(MissingPackGroupRow, {
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
},
global: {
@@ -119,6 +125,7 @@ function mountRow(
}
}
})
return { user, onLocateNode, onOpenManagerInfo }
}
describe('MissingPackGroupRow', () => {
@@ -135,27 +142,27 @@ describe('MissingPackGroupRow', () => {
describe('Basic Rendering', () => {
it('renders pack name from packId', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('my-pack')
renderRow()
expect(screen.getByText(/my-pack/)).toBeInTheDocument()
})
it('renders "Unknown pack" when packId is null', () => {
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).toContain('Unknown pack')
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.getByText(/Unknown pack/)).toBeInTheDocument()
})
it('renders loading text when isResolving is true', () => {
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
expect(wrapper.text()).toContain('Loading')
renderRow({ group: makeGroup({ isResolving: true }) })
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('renders node count', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
renderRow()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
renderRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: `Node${i}`,
@@ -164,39 +171,39 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(wrapper.text()).toContain('(5)')
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('MissingA')
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
expect(wrapper.text()).toContain('MissingB')
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('MissingA')
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('renders all nodeTypes when expanded', async () => {
const wrapper = mountRow({
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
@@ -205,48 +212,47 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('NodeA')
expect(wrapper.text()).toContain('NodeB')
expect(wrapper.text()).toContain('NodeC')
await expand(user)
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#10')
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
})
it('emits locateNode when Locate button is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
await wrapper
.get('button[aria-label="Locate node on canvas"]')
.trigger('click')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', async () => {
const wrapper = mountRow({
const { user } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(wrapper)
await expand(user)
expect(
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
).toBe(false)
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('handles mixed nodeTypes with and without nodeId', async () => {
const wrapper = mountRow({
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
@@ -255,11 +261,11 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('WithId')
expect(wrapper.text()).toContain('WithoutId')
await expand(user)
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
wrapper.findAll('button[aria-label="Locate node on canvas"]')
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(1)
})
})
@@ -267,102 +273,103 @@ describe('MissingPackGroupRow', () => {
describe('Manager Integration', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('Install node pack')
renderRow()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).not.toContain('Install node pack')
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
const wrapper = mountRow()
expect(wrapper.text()).toContain('Search in Node Manager')
renderRow()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Installed')
renderRow()
expect(screen.getByText('Installed')).toBeInTheDocument()
})
it('shows spinner when installing', () => {
mockShouldShowManagerButtons.value = true
mockIsInstalling.value = true
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
renderRow()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('shows install button when not installed and pack found', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Install node pack')
renderRow()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
await wrapper.get('button:not([aria-label])').trigger('click')
const { user } = renderRow()
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
it('shows loading spinner when registry is loading', () => {
mockShouldShowManagerButtons.value = true
mockIsLoading.value = true
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
renderRow()
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('Info Button', () => {
it('shows Info button when showInfoButton true and packId not null', () => {
const wrapper = mountRow({ showInfoButton: true })
renderRow({ showInfoButton: true })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(true)
screen.getByRole('button', { name: 'View in Manager' })
).toBeInTheDocument()
})
it('hides Info button when showInfoButton is false', () => {
const wrapper = mountRow({ showInfoButton: false })
renderRow({ showInfoButton: false })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
screen.queryByRole('button', { name: 'View in Manager' })
).not.toBeInTheDocument()
})
it('hides Info button when packId is null', () => {
const wrapper = mountRow({
renderRow({
showInfoButton: true,
group: makeGroup({ packId: null })
})
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
screen.queryByRole('button', { name: 'View in Manager' })
).not.toBeInTheDocument()
})
it('emits openManagerInfo when Info button is clicked', async () => {
const wrapper = mountRow({ showInfoButton: true })
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
const { user, onOpenManagerInfo } = renderRow({ showInfoButton: true })
await user.click(screen.getByRole('button', { name: 'View in Manager' }))
expect(onOpenManagerInfo).toHaveBeenCalledWith('my-pack')
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
expect(wrapper.text()).toContain('(0)')
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
})
})
})

View File

@@ -1,11 +1,11 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
@@ -61,8 +61,9 @@ describe('TabErrors.vue', () => {
})
})
function mountComponent(initialState = {}) {
return mount(TabErrors, {
function renderComponent(initialState = {}) {
const user = userEvent.setup()
render(TabErrors, {
global: {
plugins: [
PrimeVue,
@@ -86,15 +87,16 @@ describe('TabErrors.vue', () => {
}
}
})
return { user }
}
it('renders "no errors" state when store is empty', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('No errors')
renderComponent()
expect(screen.getByText('No errors')).toBeInTheDocument()
})
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
@@ -104,12 +106,9 @@ describe('TabErrors.vue', () => {
}
})
// Group title should be the raw message from store
expect(wrapper.text()).toContain('Server Error: No outputs')
// Item message should be localized desc
expect(wrapper.text()).toContain('Prompt has no outputs')
// Details should not be rendered for prompt errors
expect(wrapper.text()).not.toContain('Error details')
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getByText('Prompt has no outputs')).toBeInTheDocument()
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})
it('renders node validation errors grouped by class_type', async () => {
@@ -118,7 +117,7 @@ describe('TabErrors.vue', () => {
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
renderComponent({
executionError: {
lastNodeErrors: {
'6': {
@@ -131,10 +130,10 @@ describe('TabErrors.vue', () => {
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('#6')
expect(wrapper.text()).toContain('CLIP Text Encode')
expect(wrapper.text()).toContain('Required input is missing')
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
expect(screen.getByText('#6')).toBeInTheDocument()
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
})
it('renders runtime execution errors from WebSocket', async () => {
@@ -143,7 +142,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -157,17 +156,17 @@ describe('TabErrors.vue', () => {
}
})
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
expect(wrapper.text()).toContain('Line 1')
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
})
it('filters errors based on search query', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'1': {
@@ -182,14 +181,17 @@ describe('TabErrors.vue', () => {
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('KSampler')
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
1
)
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
const searchInput = wrapper.find('input')
await searchInput.setValue('Missing text input')
await user.type(screen.getByRole('textbox'), 'Missing text input')
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).not.toContain('KSampler')
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
1
)
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
})
it('calls copyToClipboard when copy button is clicked', async () => {
@@ -198,7 +200,7 @@ describe('TabErrors.vue', () => {
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'1': {
@@ -209,9 +211,7 @@ describe('TabErrors.vue', () => {
}
})
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
await user.click(screen.getByTestId('error-card-copy'))
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -222,7 +222,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -236,15 +236,9 @@ describe('TabErrors.vue', () => {
}
})
// Runtime error panel title should show class type
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
})
})

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import type { Slots } from 'vue'
import { h } from 'vue'
@@ -103,58 +104,56 @@ describe('WidgetActions', () => {
})
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
return mount(WidgetActions, {
function renderWidgetActions(
widget: IBaseWidget,
node: LGraphNode,
extraProps: Record<string, unknown> = {}
) {
const user = userEvent.setup()
const onResetToDefault = vi.fn()
render(WidgetActions, {
props: {
widget,
node,
label: 'Test Widget'
label: 'Test Widget',
onResetToDefault,
...extraProps
},
global: {
plugins: [i18n]
}
})
return { user, onResetToDefault }
}
it('shows reset button when widget has default value', () => {
const widget = createMockWidget()
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
renderWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton).toBeDefined()
expect(screen.getByRole('button', { name: /Reset/ })).toBeInTheDocument()
})
it('emits resetToDefault with default value when reset button clicked', async () => {
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const { user, onResetToDefault } = renderWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
await user.click(screen.getByRole('button', { name: /Reset/ }))
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
expect(onResetToDefault).toHaveBeenCalledTimes(1)
expect(onResetToDefault).toHaveBeenCalledWith(42)
})
it('disables reset button when value equals default', () => {
const widget = createMockWidget(42)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
renderWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton?.attributes('disabled')).toBeDefined()
expect(screen.getByRole('button', { name: /Reset/ })).toBeDisabled()
})
it('does not show reset button when no default value exists', () => {
@@ -165,13 +164,11 @@ describe('WidgetActions', () => {
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
renderWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton).toBeUndefined()
expect(
screen.queryByRole('button', { name: /Reset/ })
).not.toBeInTheDocument()
})
it('uses fallback default for INT type without explicit default', async () => {
@@ -182,15 +179,11 @@ describe('WidgetActions', () => {
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const { user, onResetToDefault } = renderWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
await user.click(screen.getByRole('button', { name: /Reset/ }))
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
expect(onResetToDefault).toHaveBeenCalledWith(0)
})
it('uses first option as default for combo without explicit default', async () => {
@@ -202,15 +195,11 @@ describe('WidgetActions', () => {
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const { user, onResetToDefault } = renderWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
await user.click(screen.getByRole('button', { name: /Reset/ }))
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
expect(onResetToDefault).toHaveBeenCalledWith('option1')
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
@@ -248,7 +237,8 @@ describe('WidgetActions', () => {
disambiguatingSourceNodeId: '1'
})
const wrapper = mount(WidgetActions, {
const user = userEvent.setup()
render(WidgetActions, {
props: {
widget,
node,
@@ -261,11 +251,7 @@ describe('WidgetActions', () => {
}
})
const hideButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Hide input'))
expect(hideButton).toBeDefined()
await hideButton?.trigger('click')
await user.click(screen.getByRole('button', { name: /Hide input/ }))
expect(
promotionStore.isPromoted('graph-test', 4, {

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -14,7 +14,8 @@ const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
StubWidgetComponent: {
name: 'StubWidget',
props: ['widget', 'modelValue', 'nodeId', 'nodeType'],
template: '<div class="stub-widget" />'
template:
'<div class="stub-widget" :data-widget-options="JSON.stringify(widget?.options)" :data-widget-type="widget?.type" :data-widget-name="widget?.name" :data-widget-value="String(widget?.value)" />'
}
}))
@@ -132,11 +133,11 @@ function createMockPromotedWidgetView(
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function mountWidgetItem(
function renderWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
) {
return mount(WidgetItem, {
return render(WidgetItem, {
props: { widget, node },
global: {
plugins: [i18n],
@@ -148,6 +149,18 @@ function mountWidgetItem(
})
}
function getStubWidget(container: Element) {
// eslint-disable-next-line testing-library/no-node-access
const el = container.querySelector('.stub-widget')
if (!el) throw new Error('stub-widget not found')
return {
options: JSON.parse(el.getAttribute('data-widget-options') ?? 'null'),
type: el.getAttribute('data-widget-type'),
name: el.getAttribute('data-widget-name'),
value: el.getAttribute('data-widget-value')
}
}
describe('WidgetItem', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -159,10 +172,10 @@ describe('WidgetItem', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
})
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.props('widget').options).toEqual({
expect(stub.options).toEqual({
values: ['a', 'b', 'c']
})
})
@@ -172,34 +185,34 @@ describe('WidgetItem', () => {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const widget = createMockPromotedWidgetView(expectedOptions)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.props('widget').options).toEqual(expectedOptions)
expect(stub.options).toEqual(expectedOptions)
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.props('widget').type).toBe('combo')
expect(stub.type).toBe('combo')
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.props('widget').name).toBe('ckpt_name')
expect(stub.name).toBe('ckpt_name')
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.props('widget').value).toBe('model_a.safetensors')
expect(stub.value).toBe('model_a.safetensors')
})
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -23,25 +24,48 @@ describe('NodeSearchCategorySidebar', () => {
setupTestPinia()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
async function createRender(props = {}) {
const user = userEvent.setup()
const onUpdateSelectedCategory = vi.fn()
const baseProps = { selectedCategory: 'most-relevant', ...props }
let currentProps = { ...baseProps }
let rerenderFn: (
p: typeof baseProps & Record<string, unknown>
) => void = () => {}
function makeProps(overrides = {}) {
const merged = { ...currentProps, ...overrides }
return {
...merged,
'onUpdate:selectedCategory': (val: string) => {
onUpdateSelectedCategory(val)
currentProps = { ...currentProps, selectedCategory: val }
rerenderFn(makeProps())
}
}
}
const result = render(NodeSearchCategorySidebar, {
props: makeProps(),
global: { plugins: [testI18n] }
})
rerenderFn = (p) => result.rerender(p)
await nextTick()
return wrapper
return { user, onUpdateSelectedCategory }
}
async function clickCategory(
wrapper: ReturnType<typeof mount>,
user: ReturnType<typeof userEvent.setup>,
text: string,
exact = false
) {
const btn = wrapper
.findAll('button')
.find((b) => (exact ? b.text().trim() === text : b.text().includes(text)))
const buttons = screen.getAllByRole('button')
const btn = buttons.find((b) =>
exact ? b.textContent?.trim() === text : b.textContent?.includes(text)
)
expect(btn, `Expected to find a button with text "${text}"`).toBeDefined()
await btn!.trigger('click')
await user.click(btn!)
await nextTick()
}
@@ -56,37 +80,35 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
await createRender()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
expect(screen.getByText('Most relevant')).toBeInTheDocument()
expect(screen.getByText('Recents')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(screen.getByText('Essentials')).toBeInTheDocument()
expect(screen.getByText('Blueprints')).toBeInTheDocument()
expect(screen.getByText('Partner')).toBeInTheDocument()
expect(screen.getByText('Comfy')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
})
it('should mark the selected preset category as selected', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
await createRender({ selectedCategory: 'most-relevant' })
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
expect(screen.getByTestId('category-most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
expect(mostRelevantBtn.attributes('aria-current')).toBe('true')
})
it('should emit update:selectedCategory when preset is clicked', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const { user, onUpdateSelectedCategory } = await createRender({
selectedCategory: 'most-relevant'
})
await clickCategory(wrapper, 'Favorites')
await clickCategory(user, 'Favorites')
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'favorites'
])
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('favorites')
})
})
@@ -99,11 +121,11 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
await createRender()
expect(wrapper.text()).toContain('sampling')
expect(wrapper.text()).toContain('loaders')
expect(wrapper.text()).toContain('conditioning')
expect(screen.getByText('sampling')).toBeInTheDocument()
expect(screen.getByText('loaders')).toBeInTheDocument()
expect(screen.getByText('conditioning')).toBeInTheDocument()
})
it('should emit update:selectedCategory when category is clicked', async () => {
@@ -112,13 +134,11 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
const { user, onUpdateSelectedCategory } = await createRender()
await clickCategory(wrapper, 'sampling')
await clickCategory(user, 'sampling')
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
})
})
@@ -131,14 +151,16 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
const { user } = await createRender()
expect(wrapper.text()).not.toContain('advanced')
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
await clickCategory(wrapper, 'sampling')
await clickCategory(user, 'sampling')
expect(wrapper.text()).toContain('advanced')
expect(wrapper.text()).toContain('basic')
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
expect(screen.getByText('basic')).toBeInTheDocument()
})
})
it('should collapse sibling category when another is expanded', async () => {
@@ -150,17 +172,21 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
const { user } = await createRender()
// Expand sampling
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
// Expand image — sampling should collapse
await clickCategory(wrapper, 'image', true)
await clickCategory(user, 'image', true)
expect(wrapper.text()).toContain('upscale')
expect(wrapper.text()).not.toContain('advanced')
await waitFor(() => {
expect(screen.getByText('upscale')).toBeInTheDocument()
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
})
})
it('should emit update:selectedCategory when subcategory is clicked', async () => {
@@ -170,16 +196,19 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
const { user, onUpdateSelectedCategory } = await createRender()
// Expand sampling category
await clickCategory(wrapper, 'sampling', true)
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
// Click on advanced subcategory
await clickCategory(wrapper, 'advanced')
await clickCategory(user, 'advanced')
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['sampling/advanced'])
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['sampling/advanced'])
})
})
@@ -190,13 +219,12 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'sampling' })
await createRender({ selectedCategory: 'sampling' })
expect(
wrapper
.find('[data-testid="category-sampling"]')
.attributes('aria-current')
).toBe('true')
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
'aria-current',
'true'
)
})
it('should emit selected subcategory when expanded', async () => {
@@ -206,14 +234,19 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const { user, onUpdateSelectedCategory } = await createRender({
selectedCategory: 'most-relevant'
})
// Expand and click subcategory
await clickCategory(wrapper, 'sampling', true)
await clickCategory(wrapper, 'advanced')
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await clickCategory(user, 'advanced')
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['sampling/advanced'])
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['sampling/advanced'])
})
})
@@ -225,29 +258,31 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
const { user, onUpdateSelectedCategory } = await createRender()
// Only top-level visible initially
expect(wrapper.text()).toContain('api')
expect(wrapper.text()).not.toContain('image')
expect(wrapper.text()).not.toContain('BFL')
expect(screen.getByText('api')).toBeInTheDocument()
expect(screen.queryByText('image')).not.toBeInTheDocument()
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
// Expand api
await clickCategory(wrapper, 'api', true)
expect(wrapper.text()).toContain('image')
expect(wrapper.text()).not.toContain('BFL')
await clickCategory(user, 'api', true)
await waitFor(() => {
expect(screen.getByText('image')).toBeInTheDocument()
})
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
// Expand image
await clickCategory(wrapper, 'image', true)
expect(wrapper.text()).toContain('BFL')
await clickCategory(user, 'image', true)
await waitFor(() => {
expect(screen.getByText('BFL')).toBeInTheDocument()
})
// Click BFL and verify emission
await clickCategory(wrapper, 'BFL', true)
await clickCategory(user, 'BFL', true)
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['api/image/BFL'])
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['api/image/BFL'])
})
it('should emit category without root/ prefix', async () => {
@@ -256,10 +291,10 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper()
const { user, onUpdateSelectedCategory } = await createRender()
await clickCategory(wrapper, 'sampling')
await clickCategory(user, 'sampling')
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
})
})

View File

@@ -1,9 +1,8 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
@@ -32,14 +31,27 @@ describe('NodeSearchContent', () => {
vi.restoreAllMocks()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchContent, {
props: { filters: [], ...props },
async function renderComponent(props = {}) {
const user = userEvent.setup()
const onAddNode = vi.fn()
const onHoverNode = vi.fn()
const onRemoveFilter = vi.fn()
const onAddFilter = vi.fn()
render(NodeSearchContent, {
props: {
filters: [],
onAddNode,
onHoverNode,
onRemoveFilter,
onAddFilter,
...props
},
global: {
plugins: [testI18n],
stubs: {
NodeSearchListItem: {
template: '<div class="node-item">{{ nodeDef.display_name }}</div>',
template:
'<div class="node-item" data-testid="node-item">{{ nodeDef.display_name }}</div>',
props: [
'nodeDef',
'currentQuery',
@@ -52,7 +64,7 @@ describe('NodeSearchContent', () => {
}
})
await nextTick()
return wrapper
return { user, onAddNode, onHoverNode, onRemoveFilter, onAddFilter }
}
async function setupFavorites(
@@ -60,18 +72,10 @@ describe('NodeSearchContent', () => {
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
const result = await renderComponent()
await result.user.click(screen.getByTestId('category-favorites'))
await nextTick()
return wrapper
}
function getResultItems(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="result-item"]')
}
function getNodeItems(wrapper: VueWrapper) {
return wrapper.findAll('.node-item')
return result
}
describe('category selection', () => {
@@ -88,11 +92,11 @@ describe('NodeSearchContent', () => {
useNodeDefStore().nodeDefsByName['FrequentNode']
])
const wrapper = await createWrapper()
await renderComponent()
const items = getNodeItems(wrapper)
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Frequent Node')
expect(items[0]).toHaveTextContent('Frequent Node')
})
it('should show only bookmarked nodes when Favorites is selected', async () => {
@@ -110,13 +114,13 @@ describe('NodeSearchContent', () => {
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
const items = getNodeItems(wrapper)
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Bookmarked')
expect(items[0]).toHaveTextContent('Bookmarked')
})
it('should show empty state when no bookmarks exist', async () => {
@@ -125,11 +129,11 @@ describe('NodeSearchContent', () => {
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect(wrapper.text()).toContain('No results')
expect(screen.getByText('No results')).toBeInTheDocument()
})
it('should show only CustomNodes when Extensions is selected', async () => {
@@ -154,13 +158,13 @@ describe('NodeSearchContent', () => {
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-extensions'))
await nextTick()
const items = getNodeItems(wrapper)
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
expect(items[0]).toHaveTextContent('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
@@ -171,10 +175,10 @@ describe('NodeSearchContent', () => {
})
])
const wrapper = await createWrapper()
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
false
)
await renderComponent()
expect(
screen.queryByTestId('category-essentials')
).not.toBeInTheDocument()
})
it('should show only essential nodes when Essentials is selected', async () => {
@@ -191,13 +195,13 @@ describe('NodeSearchContent', () => {
])
await nextTick()
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-essentials'))
await nextTick()
const items = getNodeItems(wrapper)
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
expect(items[0]).toHaveTextContent('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
@@ -219,11 +223,11 @@ describe('NodeSearchContent', () => {
})
])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-sampling"]').trigger('click')
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts).toHaveLength(2)
expect(texts).toContain('KSampler')
expect(texts).toContain('KSampler Advanced')
@@ -245,18 +249,18 @@ describe('NodeSearchContent', () => {
})
])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-sampling"]').trigger('click')
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(1)
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
const input = wrapper.find('input[type="text"]')
await input.setValue('Load')
const input = screen.getByRole('combobox')
await user.type(input, 'Load')
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(true)
})
it('should clear search query when category changes', async () => {
@@ -264,56 +268,58 @@ describe('NodeSearchContent', () => {
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
const wrapper = await createWrapper()
const { user } = await renderComponent()
const input = wrapper.find('input[type="text"]')
await input.setValue('test query')
const input = screen.getByRole('combobox')
await user.type(input, 'test query')
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('test query')
expect(input).toHaveValue('test query')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('')
expect(input).toHaveValue('')
})
it('should reset selected index when search query changes', async () => {
const wrapper = await setupFavorites([
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'ArrowDown' })
const input = screen.getByRole('combobox')
await user.click(input)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
'aria-selected',
'true'
)
await input.setValue('Node')
await user.type(input, 'Node')
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
it('should reset selected index when category changes', async () => {
const wrapper = await setupFavorites([
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'ArrowDown' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}')
await nextTick()
await wrapper
.find('[data-testid="category-most-relevant"]')
.trigger('click')
await user.click(screen.getByTestId('category-most-relevant'))
await nextTick()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
@@ -321,106 +327,105 @@ describe('NodeSearchContent', () => {
describe('keyboard and mouse interaction', () => {
it('should navigate results with ArrowDown/ArrowUp and clamp to bounds', async () => {
const wrapper = await setupFavorites([
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const input = wrapper.find('input[type="text"]')
await user.click(screen.getByRole('combobox'))
const selectedIndex = () =>
getResultItems(wrapper).findIndex(
(r) => r.attributes('aria-selected') === 'true'
)
screen
.getAllByTestId('result-item')
.findIndex((r) => r.getAttribute('aria-selected') === 'true')
expect(selectedIndex()).toBe(0)
await input.trigger('keydown', { key: 'ArrowDown' })
await user.keyboard('{ArrowDown}')
await nextTick()
expect(selectedIndex()).toBe(1)
await input.trigger('keydown', { key: 'ArrowDown' })
await user.keyboard('{ArrowDown}')
await nextTick()
expect(selectedIndex()).toBe(2)
await input.trigger('keydown', { key: 'ArrowUp' })
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(1)
// Navigate to first, then try going above — should clamp
await input.trigger('keydown', { key: 'ArrowUp' })
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(0)
await input.trigger('keydown', { key: 'ArrowUp' })
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(0)
})
it('should select current result with Enter key', async () => {
const wrapper = await setupFavorites([
const { user, onAddNode } = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await nextTick()
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' })
)
})
it('should select item on hover', async () => {
const wrapper = await setupFavorites([
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const results = getResultItems(wrapper)
await results[1].trigger('mouseenter')
const results = screen.getAllByTestId('result-item')
await user.hover(results[1])
await nextTick()
expect(results[1].attributes('aria-selected')).toBe('true')
expect(results[1]).toHaveAttribute('aria-selected', 'true')
})
it('should add node on click', async () => {
const wrapper = await setupFavorites([
const { user, onAddNode } = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await getResultItems(wrapper)[0].trigger('click')
await user.click(screen.getAllByTestId('result-item')[0])
await nextTick()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' }),
expect.any(PointerEvent)
)
})
})
describe('hoverNode emission', () => {
it('should emit hoverNode with the currently selected node', async () => {
const wrapper = await setupFavorites([
const { onHoverNode } = await setupFavorites([
{ name: 'HoverNode', display_name: 'Hover Node' }
])
const emitted = wrapper.emitted('hoverNode')!
expect(emitted[emitted.length - 1][0]).toMatchObject({
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toMatchObject({
name: 'HoverNode'
})
})
it('should emit null hoverNode when no results', async () => {
const wrapper = await createWrapper()
const { user, onHoverNode } = await renderComponent()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
const emitted = wrapper.emitted('hoverNode')!
expect(emitted[emitted.length - 1][0]).toBeNull()
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toBeNull()
})
})
@@ -434,7 +439,7 @@ describe('NodeSearchContent', () => {
})
])
const wrapper = await createWrapper({
await renderComponent({
filters: [
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
@@ -443,9 +448,7 @@ describe('NodeSearchContent', () => {
]
})
expect(
wrapper.findAll('[data-testid="filter-chip"]').length
).toBeGreaterThan(0)
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
})
})
@@ -471,42 +474,41 @@ describe('NodeSearchContent', () => {
it('should emit removeFilter on backspace', async () => {
const filters = createFilters(1)
const wrapper = await createWrapper({ filters })
const { user, onRemoveFilter } = await renderComponent({ filters })
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'Backspace' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
await nextTick()
await input.trigger('keydown', { key: 'Backspace' })
await user.keyboard('{Backspace}')
await nextTick()
expect(wrapper.emitted('removeFilter')).toHaveLength(1)
expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
value: 'IMAGE'
})
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
})
it('should not interact with chips when no filters exist', async () => {
const wrapper = await createWrapper({ filters: [] })
const { user, onRemoveFilter } = await renderComponent({ filters: [] })
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'Backspace' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
await nextTick()
expect(wrapper.emitted('removeFilter')).toBeUndefined()
expect(onRemoveFilter).not.toHaveBeenCalled()
})
it('should remove chip when clicking its delete button', async () => {
const filters = createFilters(1)
const wrapper = await createWrapper({ filters })
const { user, onRemoveFilter } = await renderComponent({ filters })
const deleteBtn = wrapper.find('[data-testid="chip-delete"]')
await deleteBtn.trigger('click')
await user.click(screen.getByTestId('chip-delete'))
await nextTick()
expect(wrapper.emitted('removeFilter')).toHaveLength(1)
expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
value: 'IMAGE'
})
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
})
})
@@ -534,54 +536,46 @@ describe('NodeSearchContent', () => {
])
}
function findFilterBarButton(wrapper: VueWrapper, label: string) {
return wrapper
.findAll('button[aria-pressed]')
.find((b) => b.text() === label)
function findFilterBarButton(label: string) {
return screen.getAllByRole('button').find((b) => b.textContent === label)
}
async function enterFilterMode(wrapper: VueWrapper) {
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
async function enterFilterMode(user: ReturnType<typeof userEvent.setup>) {
const btn = findFilterBarButton('Input')
expect(btn).toBeDefined()
await user.click(btn!)
await nextTick()
}
function getFilterOptions(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="filter-option"]')
}
function getFilterOptionTexts(wrapper: VueWrapper) {
return getFilterOptions(wrapper).map(
(o) =>
o
.findAll('span')[0]
?.text()
.replace(/^[•·]\s*/, '')
.trim() ?? ''
)
}
function hasSidebar(wrapper: VueWrapper) {
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
function hasSidebar() {
return screen.queryByTestId('category-most-relevant') !== null
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
const { user } = await renderComponent()
expect(hasSidebar(wrapper)).toBe(true)
expect(hasSidebar()).toBe(true)
await enterFilterMode(wrapper)
await enterFilterMode(user)
expect(hasSidebar(wrapper)).toBe(false)
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
expect(hasSidebar()).toBe(false)
expect(screen.getAllByTestId('filter-option').length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
const texts = getFilterOptionTexts(wrapper)
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
@@ -590,140 +584,152 @@ describe('NodeSearchContent', () => {
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
await wrapper.find('input[type="text"]').setValue('IMAGE')
await user.type(screen.getByRole('combobox'), 'IMAGE')
await nextTick()
const texts = getFilterOptionTexts(wrapper)
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
await user.type(screen.getByRole('combobox'), 'NONEXISTENT_TYPE')
await nextTick()
expect(wrapper.text()).toContain('No results')
expect(screen.getByText('No results')).toBeInTheDocument()
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
const imageOption = getFilterOptions(wrapper).find((o) =>
o.text().includes('IMAGE')
)
await imageOption!.trigger('click')
const imageOption = screen
.getAllByTestId('filter-option')
.find((o) => o.textContent?.includes('IMAGE'))
await user.click(imageOption!)
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
await getFilterOptions(wrapper)[0].trigger('click')
await user.click(screen.getAllByTestId('filter-option')[0])
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
expect(hasSidebar()).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
const input = wrapper.find('input[type="text"]')
await user.click(screen.getByRole('combobox'))
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
'true'
)
await input.trigger('keydown', { key: 'ArrowDown' })
await user.keyboard('{ArrowDown}')
await nextTick()
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
expect(screen.getAllByTestId('filter-option')[1]).toHaveAttribute(
'aria-selected',
'true'
)
await input.trigger('keydown', { key: 'ArrowUp' })
await user.keyboard('{ArrowUp}')
await nextTick()
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await user.click(findFilterBarButton('Input')!)
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
expect(hasSidebar()).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
const input = wrapper.find('input[type="text"]')
await input.setValue('IMAGE')
const input = screen.getByRole('combobox')
await user.type(input, 'IMAGE')
await nextTick()
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await user.click(findFilterBarButton('Input')!)
await nextTick()
await nextTick()
await enterFilterMode(wrapper)
await enterFilterMode(user)
expect((input.element as HTMLInputElement).value).toBe('')
expect(input).toHaveValue('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const { user } = await renderComponent()
await enterFilterMode(user)
expect(hasSidebar(wrapper)).toBe(false)
expect(hasSidebar()).toBe(false)
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
await cancelBtn.trigger('click')
await user.click(screen.getByTestId('cancel-filter'))
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
expect(hasSidebar()).toBe(true)
})
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -30,54 +31,59 @@ describe(NodeSearchFilterBar, () => {
])
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchFilterBar, {
props,
async function createRender(props = {}) {
const user = userEvent.setup()
const onSelectChip = vi.fn()
const { container } = render(NodeSearchFilterBar, {
props: { onSelectChip, ...props },
global: { plugins: [testI18n] }
})
await nextTick()
return wrapper
const view = within(container as HTMLElement)
return { user, onSelectChip, view }
}
it('should render all filter chips', async () => {
const wrapper = await createWrapper()
const { view } = await createRender()
const buttons = wrapper.findAll('button')
const buttons = view.getAllByRole('button')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
expect(buttons[0]).toHaveTextContent('Blueprints')
expect(buttons[1]).toHaveTextContent('Partner Nodes')
expect(buttons[2]).toHaveTextContent('Essentials')
expect(buttons[3]).toHaveTextContent('Extensions')
expect(buttons[4]).toHaveTextContent('Input')
expect(buttons[5]).toHaveTextContent('Output')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {
const wrapper = await createWrapper({ activeChipKey: 'input' })
const { view } = await createRender({ activeChipKey: 'input' })
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
expect(view.getByRole('button', { name: 'Input' })).toHaveAttribute(
'aria-pressed',
'true'
)
})
it('should not mark chips as pressed when activeChipKey does not match', async () => {
const wrapper = await createWrapper({ activeChipKey: null })
const { view } = await createRender({ activeChipKey: null })
wrapper.findAll('button').forEach((btn) => {
expect(btn.attributes('aria-pressed')).toBe('false')
view.getAllByRole('button').forEach((btn) => {
expect(btn).toHaveAttribute('aria-pressed', 'false')
})
})
it('should emit selectChip with chip data when clicked', async () => {
const wrapper = await createWrapper()
const { user, onSelectChip, view } = await createRender()
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
await inputBtn?.trigger('click')
await user.click(view.getByRole('button', { name: 'Input' }))
const emitted = wrapper.emitted('selectChip')!
expect(emitted[0][0]).toMatchObject({
key: 'input',
label: 'Input',
filter: expect.anything()
})
expect(onSelectChip).toHaveBeenCalledWith(
expect.objectContaining({
key: 'input',
label: 'Input',
filter: expect.anything()
})
)
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
@@ -59,7 +60,7 @@ describe('NodeSearchInput', () => {
vi.restoreAllMocks()
})
function createWrapper(
function createRender(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
@@ -67,101 +68,128 @@ describe('NodeSearchInput', () => {
filterQuery: string
}> = {}
) {
return mount(NodeSearchInput, {
const user = userEvent.setup()
const onUpdateSearchQuery = vi.fn()
const onUpdateFilterQuery = vi.fn()
const onCancelFilter = vi.fn()
const onSelectCurrent = vi.fn()
const onNavigateDown = vi.fn()
const onNavigateUp = vi.fn()
render(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
'onUpdate:searchQuery': onUpdateSearchQuery,
'onUpdate:filterQuery': onUpdateFilterQuery,
onCancelFilter,
onSelectCurrent,
onNavigateDown,
onNavigateUp,
...props
},
global: { plugins: [testI18n] }
})
return {
user,
onUpdateSearchQuery,
onUpdateFilterQuery,
onCancelFilter,
onSelectCurrent,
onNavigateDown,
onNavigateUp
}
}
it('should route input to searchQuery when no active filter', async () => {
const wrapper = createWrapper()
await wrapper.find('input').setValue('test search')
const { user, onUpdateSearchQuery } = createRender()
await user.type(screen.getByRole('combobox'), 'test search')
expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
expect(onUpdateSearchQuery).toHaveBeenLastCalledWith('test search')
})
it('should route input to filterQuery when active filter is set', async () => {
const wrapper = createWrapper({
const { user, onUpdateFilterQuery, onUpdateSearchQuery } = createRender({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('input').setValue('IMAGE')
await user.type(screen.getByRole('combobox'), 'IMAGE')
expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
expect(onUpdateFilterQuery).toHaveBeenLastCalledWith('IMAGE')
expect(onUpdateSearchQuery).not.toHaveBeenCalled()
})
it('should show filter label placeholder when active filter is set', () => {
const wrapper = createWrapper({
createRender({
activeFilter: createActiveFilter('Input')
})
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('input')
expect(screen.getByRole('combobox')).toHaveAttribute(
'placeholder',
expect.stringContaining('input')
)
})
it('should show add node placeholder when no active filter', () => {
const wrapper = createWrapper()
createRender()
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('Add a node')
expect(screen.getByRole('combobox')).toHaveAttribute(
'placeholder',
expect.stringContaining('Add a node')
)
})
it('should hide filter chips when active filter is set', () => {
const wrapper = createWrapper({
createRender({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
expect(screen.queryAllByTestId('filter-chip')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
const wrapper = createWrapper({
createRender({
filters: [createFilter('input', 'IMAGE')]
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
expect(screen.getAllByTestId('filter-chip')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const wrapper = createWrapper({
const { user, onCancelFilter } = createRender({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
await user.click(screen.getByTestId('cancel-filter'))
expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
expect(onCancelFilter).toHaveBeenCalledOnce()
})
it('should emit selectCurrent on Enter', async () => {
const wrapper = createWrapper()
const { user, onSelectCurrent } = createRender()
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
expect(wrapper.emitted('selectCurrent')).toHaveLength(1)
expect(onSelectCurrent).toHaveBeenCalledOnce()
})
it('should emit navigateDown on ArrowDown', async () => {
const wrapper = createWrapper()
const { user, onNavigateDown } = createRender()
await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}')
expect(wrapper.emitted('navigateDown')).toHaveLength(1)
expect(onNavigateDown).toHaveBeenCalledOnce()
})
it('should emit navigateUp on ArrowUp', async () => {
const wrapper = createWrapper()
const { user, onNavigateUp } = createRender()
await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowUp}')
expect(wrapper.emitted('navigateUp')).toHaveLength(1)
expect(onNavigateUp).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
/* eslint-disable vue/one-component-per-file */
import { render, fireEvent } from '@testing-library/vue'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
@@ -31,6 +32,32 @@ const VirtualGridStub = defineComponent({
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
props: {
previewUrl: { type: String, default: '' },
isVideoPreview: { type: Boolean, default: false },
previewAlt: { type: String, default: '' },
iconName: { type: String, default: '' },
iconAriaLabel: { type: String, default: '' },
iconClass: { type: String, default: '' },
iconWrapperClass: { type: String, default: '' },
primaryText: { type: String, default: '' },
secondaryText: { type: String, default: '' },
stackCount: { type: Number, default: 0 },
stackIndicatorLabel: { type: String, default: '' },
stackExpanded: { type: Boolean, default: false },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined }
},
template: `<div
class="assets-list-item-stub"
:data-preview-url="previewUrl"
:data-is-video-preview="isVideoPreview"
data-testid="assets-list-item"
><button data-testid="preview-click-trigger" @click="$emit('preview-click')" /><slot /></div>`
})
const buildAsset = (id: string, name: string): AssetItem =>
({
id,
@@ -43,21 +70,27 @@ const buildOutputItem = (asset: AssetItem): OutputStackListItem => ({
asset
})
const mountListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
function renderListView(
assetItems: OutputStackListItem[] = [],
props: Record<string, unknown> = {}
) {
return render(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
toggleStack: async () => {},
...props
},
global: {
stubs: {
VirtualGrid: VirtualGridStub
VirtualGrid: VirtualGridStub,
AssetsListItem: AssetsListItemStub
}
}
})
}
describe('AssetsSidebarListView', () => {
it('marks mp4 assets as video previews', () => {
@@ -67,14 +100,17 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(videoAsset)])
const { container } = renderListView([buildOutputItem(videoAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const stubs = container.querySelectorAll('[data-testid="assets-list-item"]')
const assetListItem = stubs[stubs.length - 1]
expect(assetListItem).toBeDefined()
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
expect(assetListItem?.props('isVideoPreview')).toBe(true)
expect(assetListItem?.getAttribute('data-preview-url')).toBe(
'/api/view/clip.mp4'
)
expect(assetListItem?.getAttribute('data-is-video-preview')).toBe('true')
})
it('uses icon fallback for text assets even when preview_url exists', () => {
@@ -84,14 +120,15 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(textAsset)])
const { container } = renderListView([buildOutputItem(textAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const stubs = container.querySelectorAll('[data-testid="assets-list-item"]')
const assetListItem = stubs[stubs.length - 1]
expect(assetListItem).toBeDefined()
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
expect(assetListItem?.getAttribute('data-preview-url')).toBe('')
expect(assetListItem?.getAttribute('data-is-video-preview')).toBe('false')
})
it('emits preview-asset when item preview is clicked', async () => {
@@ -101,16 +138,19 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
const onPreviewAsset = vi.fn()
const { container } = renderListView([buildOutputItem(imageAsset)], {
'onPreview-asset': onPreviewAsset
})
expect(assetListItem).toBeDefined()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const trigger = container.querySelector(
'[data-testid="preview-click-trigger"]'
)!
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(trigger)
assetListItem!.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
expect(onPreviewAsset).toHaveBeenCalledWith(imageAsset)
})
it('emits preview-asset when item is double-clicked', async () => {
@@ -120,15 +160,16 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
const onPreviewAsset = vi.fn()
const { container } = renderListView([buildOutputItem(imageAsset)], {
'onPreview-asset': onPreviewAsset
})
expect(assetListItem).toBeDefined()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const stub = container.querySelector('[data-testid="assets-list-item"]')!
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.dblClick(stub)
await assetListItem!.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
expect(onPreviewAsset).toHaveBeenCalledWith(imageAsset)
})
})

View File

@@ -1,9 +1,10 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
vi.mock('@vueuse/core', async () => {
@@ -91,8 +92,8 @@ describe('NodeLibrarySidebarTabV2', () => {
vi.clearAllMocks()
})
function mountComponent() {
return mount(NodeLibrarySidebarTabV2, {
function renderComponent() {
return render(NodeLibrarySidebarTabV2, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
stubs: {
@@ -103,25 +104,23 @@ describe('NodeLibrarySidebarTabV2', () => {
}
it('should render with tabs', () => {
const wrapper = mountComponent()
renderComponent()
const triggers = wrapper.findAll('[role="tab"]')
const triggers = screen.getAllByRole('tab')
expect(triggers).toHaveLength(3)
})
it('should render search box', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.find('[data-testid="search-box"]').exists()).toBe(true)
expect(screen.getByTestId('search-box')).toBeInTheDocument()
})
it('should render only the selected panel', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.find('[data-testid="essential-panel"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="all-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="blueprints-panel"]').exists()).toBe(
false
)
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -25,7 +26,9 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div class="mock-preview" />' }
default: {
template: '<div class="mock-preview" data-testid="node-preview" />'
}
}))
describe('EssentialNodeCard', () => {
@@ -52,69 +55,93 @@ describe('EssentialNodeCard', () => {
}
}
function mountComponent(
function renderComponent(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = createMockNode()
) {
return mount(EssentialNodeCard, {
props: { node },
const onClick = vi.fn()
const user = userEvent.setup()
const { container } = render(EssentialNodeCard, {
props: { node, onClick },
global: {
stubs: {
Teleport: true
}
}
})
return { user, onClick, container }
}
function getCard(container: Element) {
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const card = container.querySelector('[data-node-name]') as HTMLElement
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
return card
}
describe('rendering', () => {
it('should display the node display_name', () => {
const wrapper = mountComponent(
createMockNode({ display_name: 'Load Image' })
)
expect(wrapper.text()).toContain('Load Image')
renderComponent(createMockNode({ display_name: 'Load Image' }))
expect(screen.getAllByText('Load Image').length).toBeGreaterThan(0)
})
it('should set data-node-name attribute', () => {
const wrapper = mountComponent(
const { container } = renderComponent(
createMockNode({ display_name: 'Save Image' })
)
const card = wrapper.find('[data-node-name]')
expect(card.attributes('data-node-name')).toBe('Save Image')
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const card = container.querySelector('[data-node-name]')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(card).toHaveAttribute('data-node-name', 'Save Image')
})
it('should be draggable', () => {
const wrapper = mountComponent()
const card = wrapper.find('[draggable]')
expect(card.attributes('draggable')).toBe('true')
const { container } = renderComponent()
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const card = container.querySelector('[draggable]')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(card).toHaveAttribute('draggable', 'true')
})
})
describe('icon generation', () => {
it('should use override icon for LoadImage', () => {
const wrapper = mountComponent(createMockNode({ name: 'LoadImage' }))
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-s1.3-[lucide--image-up]')
const { container } = renderComponent(
createMockNode({ name: 'LoadImage' })
)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const icon = container.querySelector('i')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(icon).toHaveClass('icon-s1.3-[lucide--image-up]')
})
it('should use override icon for SaveImage', () => {
const wrapper = mountComponent(createMockNode({ name: 'SaveImage' }))
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-s1.3-[lucide--image-down]')
const { container } = renderComponent(
createMockNode({ name: 'SaveImage' })
)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const icon = container.querySelector('i')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(icon).toHaveClass('icon-s1.3-[lucide--image-down]')
})
it('should use override icon for ImageCrop', () => {
const wrapper = mountComponent(createMockNode({ name: 'ImageCrop' }))
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-s1.3-[lucide--crop]')
const { container } = renderComponent(
createMockNode({ name: 'ImageCrop' })
)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const icon = container.querySelector('i')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(icon).toHaveClass('icon-s1.3-[lucide--crop]')
})
it('should use kebab-case for complex node names', () => {
const wrapper = mountComponent(
const { container } = renderComponent(
createMockNode({ name: 'RecraftRemoveBackgroundNode' })
)
const icon = wrapper.find('i')
expect(icon.classes()).toContain(
'icon-[comfy--recraft-remove-background-node]'
)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const icon = container.querySelector('i')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(icon).toHaveClass('icon-[comfy--recraft-remove-background-node]')
})
it('should use default node icon when nodeDef has no name', () => {
@@ -126,21 +153,22 @@ describe('EssentialNodeCard', () => {
totalLeaves: 1,
data: undefined
}
const wrapper = mountComponent(node)
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-[comfy--node]')
const { container } = renderComponent(node)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
const icon = container.querySelector('i')
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(icon).toHaveClass('icon-[comfy--node]')
})
})
describe('events', () => {
it('should emit click event when clicked', async () => {
const node = createMockNode()
const wrapper = mountComponent(node)
const { user, onClick, container } = renderComponent(node)
await wrapper.find('div').trigger('click')
await user.click(getCard(container))
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')?.[0]).toEqual([node])
expect(onClick).toHaveBeenCalledWith(node)
})
it('should not emit click when nodeDef is undefined', async () => {
@@ -152,29 +180,27 @@ describe('EssentialNodeCard', () => {
totalLeaves: 1,
data: undefined
}
const wrapper = mountComponent(node)
const { user, onClick, container } = renderComponent(node)
await wrapper.find('div').trigger('click')
await user.click(getCard(container))
expect(wrapper.emitted('click')).toBeFalsy()
expect(onClick).not.toHaveBeenCalled()
})
})
describe('drag and drop', () => {
it('should call startDrag on dragstart', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
const { container } = renderComponent()
await card.trigger('dragstart')
await fireEvent.dragStart(getCard(container))
expect(mockStartDrag).toHaveBeenCalled()
})
it('should call handleNativeDrop on dragend', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
const { container } = renderComponent()
await card.trigger('dragend')
await fireEvent.dragEnd(getCard(container))
expect(mockHandleNativeDrop).toHaveBeenCalled()
})
@@ -182,23 +208,21 @@ describe('EssentialNodeCard', () => {
describe('hover preview', () => {
it('should show preview on mouseenter', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
const { user, container } = renderComponent()
await card.trigger('mouseenter')
await user.hover(getCard(container))
expect(wrapper.find('teleport-stub').exists()).toBe(true)
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
})
it('should hide preview after mouseleave', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
const { user, container } = renderComponent()
await card.trigger('mouseenter')
expect(wrapper.find('teleport-stub').exists()).toBe(true)
await user.hover(getCard(container))
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
await card.trigger('mouseleave')
expect(wrapper.find('teleport-stub').exists()).toBe(false)
await user.unhover(getCard(container))
expect(screen.queryByTestId('node-preview')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import { render, waitFor } from '@testing-library/vue'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -80,7 +80,7 @@ describe('EssentialNodesPanel', () => {
}
}
function mountComponent(
function renderComponent(
root = createMockRoot(),
expandedKeys: string[] = [],
flatNodes: RenderedTreeExplorerNode<ComfyNodeDefImpl>[] = []
@@ -93,7 +93,7 @@ describe('EssentialNodesPanel', () => {
return { root, flatNodes, keys }
}
}
return mount(WrapperComponent, {
return render(WrapperComponent, {
global: {
stubs: {
Teleport: true,
@@ -112,6 +112,9 @@ describe('EssentialNodesPanel', () => {
},
CollapsibleContent: {
template: '<div class="collapsible-content"><slot /></div>'
},
EssentialNodeCard: {
template: '<div data-testid="essential-node-card" />'
}
}
}
@@ -120,54 +123,61 @@ describe('EssentialNodesPanel', () => {
describe('folder rendering', () => {
it('should render all top-level folders', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAll('.collapsible-trigger')
expect(triggers).toHaveLength(3)
const { container } = renderComponent()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.collapsible-trigger')).toHaveLength(3)
})
it('should display folder labels', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('images')
expect(wrapper.text()).toContain('video')
expect(wrapper.text()).toContain('audio')
const { container } = renderComponent()
expect(container.textContent).toContain('images')
expect(container.textContent).toContain('video')
expect(container.textContent).toContain('audio')
})
})
describe('default expansion', () => {
it('should expand all folders by default when expandedKeys is empty', async () => {
const wrapper = mountComponent(createMockRoot(), [])
await nextTick()
await flushPromises()
await nextTick()
const { container } = renderComponent(createMockRoot(), [])
const roots = wrapper.findAll('.collapsible-root')
expect(roots[0].attributes('data-state')).toBe('open')
expect(roots[1].attributes('data-state')).toBe('open')
expect(roots[2].attributes('data-state')).toBe('open')
await waitFor(() => {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const roots = container.querySelectorAll('.collapsible-root')
expect(roots).toHaveLength(3)
expect(roots[0].getAttribute('data-state')).toBe('open')
expect(roots[1].getAttribute('data-state')).toBe('open')
expect(roots[2].getAttribute('data-state')).toBe('open')
})
})
it('should respect provided expandedKeys', async () => {
const wrapper = mountComponent(createMockRoot(), ['folder-audio'])
await nextTick()
const { container } = renderComponent(createMockRoot(), ['folder-audio'])
const roots = wrapper.findAll('.collapsible-root')
expect(roots[0].attributes('data-state')).toBe('closed')
expect(roots[1].attributes('data-state')).toBe('closed')
expect(roots[2].attributes('data-state')).toBe('open')
await waitFor(() => {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const roots = container.querySelectorAll('.collapsible-root')
expect(roots).toHaveLength(3)
expect(roots[0].getAttribute('data-state')).toBe('closed')
expect(roots[1].getAttribute('data-state')).toBe('closed')
expect(roots[2].getAttribute('data-state')).toBe('open')
})
})
it('should expand all provided keys', async () => {
const wrapper = mountComponent(createMockRoot(), [
const { container } = renderComponent(createMockRoot(), [
'folder-images',
'folder-video',
'folder-audio'
])
await nextTick()
const roots = wrapper.findAll('.collapsible-root')
expect(roots[0].attributes('data-state')).toBe('open')
expect(roots[1].attributes('data-state')).toBe('open')
expect(roots[2].attributes('data-state')).toBe('open')
await waitFor(() => {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const roots = container.querySelectorAll('.collapsible-root')
expect(roots).toHaveLength(3)
expect(roots[0].getAttribute('data-state')).toBe('open')
expect(roots[1].getAttribute('data-state')).toBe('open')
expect(roots[2].getAttribute('data-state')).toBe('open')
})
})
})
@@ -187,21 +197,24 @@ describe('EssentialNodesPanel', () => {
]
}
const wrapper = mountComponent(root, [])
await nextTick()
await flushPromises()
await nextTick()
const { container } = renderComponent(root, [])
const roots = wrapper.findAll('.collapsible-root')
expect(roots).toHaveLength(1)
expect(roots[0].attributes('data-state')).toBe('open')
await waitFor(() => {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const roots = container.querySelectorAll('.collapsible-root')
expect(roots).toHaveLength(1)
expect(roots[0].getAttribute('data-state')).toBe('open')
})
})
})
describe('node cards', () => {
it('should render node cards for each node in expanded folders', () => {
const wrapper = mountComponent(createMockRoot(), ['folder-images'])
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
const { container } = renderComponent(createMockRoot(), ['folder-images'])
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const cards = container.querySelectorAll(
'[data-testid="essential-node-card"]'
)
expect(cards.length).toBeGreaterThanOrEqual(2)
})
})
@@ -213,11 +226,15 @@ describe('EssentialNodesPanel', () => {
createMockNode('LoadImage'),
createMockNode('SaveImage')
]
const wrapper = mountComponent(createMockRoot(), [], flatNodes)
const { container } = renderComponent(createMockRoot(), [], flatNodes)
expect(wrapper.findAll('.collapsible-root')).toHaveLength(0)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.collapsible-root')).toHaveLength(0)
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const cards = container.querySelectorAll(
'[data-testid="essential-node-card"]'
)
expect(cards).toHaveLength(3)
})
})

View File

@@ -1,10 +1,9 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
enableAutoUnmount(afterEach)
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
@@ -99,16 +98,13 @@ describe('MediaLightbox', () => {
]
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'
})
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(MediaLightbox, {
const renderGallery = (props = {}) => {
const onUpdateActiveIndex = vi.fn()
const user = userEvent.setup()
const { rerender, container } = render(MediaLightbox, {
global: {
plugins: [i18n],
components: {
@@ -123,107 +119,118 @@ describe('MediaLightbox', () => {
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
'onUpdate:activeIndex': onUpdateActiveIndex,
...props
},
attachTo: document.getElementById('app') || undefined
container: document.body.appendChild(document.createElement('div'))
})
return { user, onUpdateActiveIndex, rerender, container }
}
it('renders overlay with role="dialog" and aria-modal', async () => {
const wrapper = mountGallery()
renderGallery()
await nextTick()
const dialog = wrapper.find('[role="dialog"]')
expect(dialog.exists()).toBe(true)
expect(dialog.attributes('aria-modal')).toBe('true')
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
it('shows navigation buttons when multiple items', async () => {
const wrapper = mountGallery()
renderGallery()
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
expect(screen.getByLabelText('Previous')).toBeInTheDocument()
expect(screen.getByLabelText('Next')).toBeInTheDocument()
})
it('hides navigation buttons for single item', async () => {
const wrapper = mountGallery({
renderGallery({
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
})
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
expect(screen.queryByLabelText('Previous')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Next')).not.toBeInTheDocument()
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
const { rerender, container } = renderGallery({ activeIndex: -1 })
expect(wrapper.find('[data-mask]').exists()).toBe(false)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelector('[data-mask]')).not.toBeInTheDocument()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
await wrapper.setProps({ activeIndex: 0 })
await rerender({
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0
})
await nextTick()
expect(wrapper.find('[data-mask]').exists()).toBe(true)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelector('[data-mask]')).toBeInTheDocument()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
it('emits update:activeIndex with -1 when close button clicked', async () => {
const wrapper = mountGallery()
const { user, onUpdateActiveIndex } = renderGallery()
await nextTick()
await wrapper.find('[aria-label="Close"]').trigger('click')
await user.click(screen.getByLabelText('Close'))
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
expect(onUpdateActiveIndex).toHaveBeenCalledWith(-1)
})
/* eslint-disable testing-library/prefer-user-event -- keyDown on dialog element for navigation, not text input */
describe('keyboard navigation', () => {
it('navigates to next item on ArrowRight', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowRight' })
await fireEvent.keyDown(screen.getByRole('dialog'), {
key: 'ArrowRight'
})
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
expect(onUpdateActiveIndex).toHaveBeenCalledWith(1)
})
it('navigates to previous item on ArrowLeft', async () => {
const wrapper = mountGallery({ activeIndex: 1 })
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 1 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await fireEvent.keyDown(screen.getByRole('dialog'), {
key: 'ArrowLeft'
})
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
expect(onUpdateActiveIndex).toHaveBeenCalledWith(0)
})
it('wraps to last item on ArrowLeft from first', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await fireEvent.keyDown(screen.getByRole('dialog'), {
key: 'ArrowLeft'
})
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
expect(onUpdateActiveIndex).toHaveBeenCalledWith(2)
})
it('closes gallery on Escape', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'Escape' })
await fireEvent.keyDown(screen.getByRole('dialog'), {
key: 'Escape'
})
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
expect(onUpdateActiveIndex).toHaveBeenCalledWith(-1)
})
})
/* eslint-enable testing-library/prefer-user-event */
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -30,8 +30,8 @@ vi.mock('@vueuse/core', () => ({
}))
describe('CompareSliderThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(CompareSliderThumbnail, {
const renderThumbnail = (props = {}) => {
return render(CompareSliderThumbnail, {
props: {
baseImageSrc: '/base-image.jpg',
overlayImageSrc: '/overlay-image.jpg',
@@ -43,42 +43,35 @@ describe('CompareSliderThumbnail', () => {
}
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
renderThumbnail()
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0]).toHaveAttribute('src', '/base-image.jpg')
expect(images[1]).toHaveAttribute('src', '/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
renderThumbnail({ alt: 'Custom Alt Text' })
const images = screen.getAllByRole('img')
expect(images[0]).toHaveAttribute('alt', 'Custom Alt Text')
expect(images[1]).toHaveAttribute('alt', 'Custom Alt Text')
})
it('applies clip-path style to overlay image', () => {
const wrapper = mountThumbnail()
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageStyle = overlayLazyImage.props('imageStyle')
expect(imageStyle.clipPath).toContain('inset')
renderThumbnail()
const images = screen.getAllByRole('img')
expect(images[1].style.clipPath).toContain('inset')
})
it('renders slider divider', () => {
const wrapper = mountThumbnail()
const divider = wrapper.find('.bg-white\\/30')
expect(divider.exists()).toBe(true)
renderThumbnail()
const divider = screen.getByTestId('compare-slider-divider')
expect(divider).toBeDefined()
})
it('positions slider based on default value', () => {
const wrapper = mountThumbnail()
const divider = wrapper.find('.bg-white\\/30')
expect(divider.attributes('style')).toContain('left: 50%')
})
it('passes isHovered prop to BaseThumbnail', () => {
const wrapper = mountThumbnail({ isHovered: true })
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('isHovered')).toBe(true)
renderThumbnail()
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
})

View File

@@ -23,6 +23,7 @@
}"
/>
<div
data-testid="compare-slider-divider"
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white/30 backdrop-blur-sm"
:style="{
left: `${sliderPosition}%`

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
@@ -21,8 +21,8 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}))
describe('HoverDissolveThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(HoverDissolveThumbnail, {
const renderThumbnail = (props = {}) => {
return render(HoverDissolveThumbnail, {
props: {
baseImageSrc: '/base-image.jpg',
overlayImageSrc: '/overlay-image.jpg',
@@ -35,75 +35,31 @@ describe('HoverDissolveThumbnail', () => {
}
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
renderThumbnail()
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0]).toHaveAttribute('src', '/base-image.jpg')
expect(images[1]).toHaveAttribute('src', '/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
renderThumbnail({ alt: 'Custom Alt Text' })
const images = screen.getAllByRole('img')
expect(images[0]).toHaveAttribute('alt', 'Custom Alt Text')
expect(images[1]).toHaveAttribute('alt', 'Custom Alt Text')
})
it('makes overlay image visible when hovered', () => {
const wrapper = mountThumbnail({ isHovered: true })
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-100')
expect(classString).not.toContain('opacity-0')
renderThumbnail({ isHovered: true })
const images = screen.getAllByRole('img')
expect(images[1]).toHaveClass('opacity-100')
expect(images[1]).not.toHaveClass('opacity-0')
})
it('makes overlay image hidden when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-0')
expect(classString).not.toContain('opacity-100')
})
it('passes isHovered prop to BaseThumbnail', () => {
const wrapper = mountThumbnail({ isHovered: true })
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('isHovered')).toBe(true)
})
it('applies transition classes to overlay image', () => {
const wrapper = mountThumbnail()
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transition-opacity')
expect(classString).toContain('duration-300')
})
it('applies correct positioning to both images', () => {
const wrapper = mountThumbnail()
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
// Check base image
const baseImageClass = lazyImages[0].props('imageClass')
const baseClassList = Array.isArray(baseImageClass)
? baseImageClass
: baseImageClass.split(' ')
expect(baseClassList).toContain('size-full')
// Check overlay image
const overlayImageClass = lazyImages[1].props('imageClass')
const overlayClassList = Array.isArray(overlayImageClass)
? overlayImageClass
: overlayImageClass.split(' ')
expect(overlayClassList).toContain('size-full')
renderThumbnail({ isHovered: false })
const images = screen.getAllByRole('img')
expect(images[1]).toHaveClass('opacity-0')
expect(images[1]).not.toHaveClass('opacity-100')
})
})

View File

@@ -1,5 +1,5 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -196,32 +196,39 @@ describe('CurrentUserPopoverLegacy', () => {
mockAuthStoreState.isFetchingBalance = false
})
const mountComponent = (): VueWrapper => {
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const onClose = vi.fn()
const user = userEvent.setup()
return mount(CurrentUserPopoverLegacy, {
render(CurrentUserPopoverLegacy, {
global: {
plugins: [i18n],
stubs: {
Divider: true
}
},
props: {
onClose
}
})
return { user, onClose }
}
it('renders user information correctly', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.text()).toContain('Test User')
expect(wrapper.text()).toContain('test@example.com')
expect(screen.getByText('Test User')).toBeInTheDocument()
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
const wrapper = mountComponent()
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
@@ -232,103 +239,72 @@ describe('CurrentUserPopoverLegacy', () => {
}
})
// Verify the formatted credit string (1000) is rendered in the DOM
expect(wrapper.text()).toContain('1000')
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
const wrapper = mountComponent()
renderComponent()
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
expect(wrapper.text()).toContain('Log Out')
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
expect(screen.getByText('Log Out')).toBeInTheDocument()
})
it('opens user settings and emits close event when settings item is clicked', async () => {
const wrapper = mountComponent()
const { user, onClose } = renderComponent()
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
expect(settingsItem.exists()).toBe(true)
expect(screen.getByTestId('user-settings-menu-item')).toBeInTheDocument()
await settingsItem.trigger('click')
await user.click(screen.getByTestId('user-settings-menu-item'))
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('calls logout function and emits close event when logout item is clicked', async () => {
const wrapper = mountComponent()
const { user, onClose } = renderComponent()
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
await logoutItem.trigger('click')
await user.click(screen.getByTestId('logout-menu-item'))
// Verify handleSignOut was called
expect(mockHandleSignOut).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
const wrapper = mountComponent()
const { user, onClose } = renderComponent()
const partnerNodesItem = wrapper.find(
'[data-testid="partner-nodes-menu-item"]'
)
expect(partnerNodesItem.exists()).toBe(true)
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
await partnerNodesItem.trigger('click')
await user.click(screen.getByTestId('partner-nodes-menu-item'))
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/partner-nodes/pricing',
'_blank'
)
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
const { user, onClose } = renderComponent()
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
expect(topUpButton.exists()).toBe(true)
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
await topUpButton.trigger('click')
await user.click(screen.getByTestId('add-credits-button'))
// Verify showTopUpCreditsDialog was called
expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('opens subscription dialog and emits close event when plans & pricing item is clicked', async () => {
const wrapper = mountComponent()
const { user, onClose } = renderComponent()
const plansPricingItem = wrapper.find(
'[data-testid="plans-pricing-menu-item"]'
)
expect(plansPricingItem.exists()).toBe(true)
expect(screen.getByTestId('plans-pricing-menu-item')).toBeInTheDocument()
await plansPricingItem.trigger('click')
await user.click(screen.getByTestId('plans-pricing-menu-item'))
// Verify showPricingTable was called
expect(mockShowPricingTable).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('effective_balance_micros handling', () => {
@@ -339,7 +315,7 @@ describe('CurrentUserPopoverLegacy', () => {
currency: 'usd'
}
const wrapper = mountComponent()
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 150_000,
@@ -349,7 +325,7 @@ describe('CurrentUserPopoverLegacy', () => {
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('1500')
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
@@ -359,7 +335,7 @@ describe('CurrentUserPopoverLegacy', () => {
currency: 'usd'
}
const wrapper = mountComponent()
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 0,
@@ -369,7 +345,7 @@ describe('CurrentUserPopoverLegacy', () => {
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('0')
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
@@ -379,7 +355,7 @@ describe('CurrentUserPopoverLegacy', () => {
currency: 'usd'
}
const wrapper = mountComponent()
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: -50_000,
@@ -389,7 +365,7 @@ describe('CurrentUserPopoverLegacy', () => {
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('-500')
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
@@ -398,7 +374,7 @@ describe('CurrentUserPopoverLegacy', () => {
currency: 'usd'
}
const wrapper = mountComponent()
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
@@ -408,7 +384,7 @@ describe('CurrentUserPopoverLegacy', () => {
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('1000')
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
@@ -416,7 +392,7 @@ describe('CurrentUserPopoverLegacy', () => {
currency: 'usd'
}
const wrapper = mountComponent()
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 0,
@@ -426,7 +402,7 @@ describe('CurrentUserPopoverLegacy', () => {
maximumFractionDigits: 2
}
})
expect(wrapper.text()).toContain('0')
expect(screen.getByText('0')).toBeInTheDocument()
})
})
@@ -436,53 +412,47 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides credits section', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="add-credits-button"]').exists()).toBe(
false
)
renderComponent()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
expect(
wrapper.find('[data-testid="upgrade-to-add-credits-button"]').exists()
).toBe(false)
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
})
it('hides subscribe button', () => {
const wrapper = mountComponent()
expect(wrapper.text()).not.toContain('Subscribe Button')
renderComponent()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
})
it('hides partner nodes menu item', () => {
const wrapper = mountComponent()
renderComponent()
expect(
wrapper.find('[data-testid="partner-nodes-menu-item"]').exists()
).toBe(false)
screen.queryByTestId('partner-nodes-menu-item')
).not.toBeInTheDocument()
})
it('hides plans & pricing menu item', () => {
const wrapper = mountComponent()
renderComponent()
expect(
wrapper.find('[data-testid="plans-pricing-menu-item"]').exists()
).toBe(false)
screen.queryByTestId('plans-pricing-menu-item')
).not.toBeInTheDocument()
})
it('hides manage plan menu item', () => {
const wrapper = mountComponent()
renderComponent()
expect(
wrapper.find('[data-testid="manage-plan-menu-item"]').exists()
).toBe(false)
screen.queryByTestId('manage-plan-menu-item')
).not.toBeInTheDocument()
})
it('still shows user settings menu item', () => {
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="user-settings-menu-item"]').exists()
).toBe(true)
renderComponent()
expect(screen.getByTestId('user-settings-menu-item')).toBeInTheDocument()
})
it('still shows logout menu item', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="logout-menu-item"]').exists()).toBe(
true
)
renderComponent()
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
})
})
})

View File

@@ -1,7 +1,8 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import TopbarSubscribeButton from './TopbarSubscribeButton.vue'
@@ -46,14 +47,14 @@ vi.mock('firebase/auth', () => ({
signOut: vi.fn()
}))
function mountComponent() {
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(TopbarSubscribeButton, {
return render(TopbarSubscribeButton, {
global: {
plugins: [i18n]
}
@@ -63,17 +64,15 @@ function mountComponent() {
describe('TopbarSubscribeButton', () => {
it('renders on cloud when isFreeTier is true', () => {
mockIsCloud.value = true
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
).toBe(true)
renderComponent()
expect(screen.getByTestId('topbar-subscribe-button')).toBeInTheDocument()
})
it('hides on non-cloud distribution', () => {
mockIsCloud.value = false
const wrapper = mountComponent()
renderComponent()
expect(
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
).toBe(false)
screen.queryByTestId('topbar-subscribe-button')
).not.toBeInTheDocument()
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
@@ -184,14 +184,14 @@ const createTask = (
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const wrapper = mount({
const result = render({
template: '<div />',
setup() {
composable = useJobList()
return {}
}
})
return { wrapper, composable: composable! }
return { ...result, composable: composable! }
}
const resetStores = () => {
@@ -230,27 +230,27 @@ const flush = async () => {
}
describe('useJobList', () => {
let wrapper: ReturnType<typeof mount> | null = null
let unmount: (() => void) | null = null
let api: ReturnType<typeof useJobList> | null = null
beforeEach(() => {
vi.resetAllMocks()
resetStores()
wrapper?.unmount()
wrapper = null
unmount?.()
unmount = null
api = null
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
unmount?.()
unmount = null
api = null
vi.useRealTimers()
})
const initComposable = () => {
const mounted = mountUseJobList()
wrapper = mounted.wrapper
unmount = mounted.unmount
api = mounted.composable
return api!
}
@@ -321,8 +321,8 @@ describe('useJobList', () => {
await flush()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper?.unmount()
wrapper = null
unmount?.()
unmount = null
await flush()
expect(vi.getTimerCount()).toBe(0)
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
@@ -45,14 +45,14 @@ vi.mock('@/stores/executionStore', () => {
const mountComposable = () => {
let composable: ReturnType<typeof useQueueNotificationBanners>
const wrapper = mount({
const result = render({
template: '<div />',
setup() {
composable = useQueueNotificationBanners()
return {}
}
})
return { wrapper, composable: composable! }
return { ...result, composable: composable! }
}
describe(useQueueNotificationBanners, () => {
@@ -131,7 +131,7 @@ describe(useQueueNotificationBanners, () => {
})
it('shows queued notifications from promptQueued events', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
@@ -148,12 +148,12 @@ describe(useQueueNotificationBanners, () => {
await nextTick()
expect(composable.currentNotification.value).toBeNull()
} finally {
wrapper.unmount()
unmount()
}
})
it('shows queued pending then queued confirmation', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
@@ -182,12 +182,12 @@ describe(useQueueNotificationBanners, () => {
requestId: 1
})
} finally {
wrapper.unmount()
unmount()
}
})
it('falls back to 1 when queued batch count is invalid', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
mockApi.dispatchEvent(
@@ -200,12 +200,12 @@ describe(useQueueNotificationBanners, () => {
count: 1
})
} finally {
wrapper.unmount()
unmount()
}
})
it('shows a completed notification from a finished batch', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
await runBatch({
@@ -225,12 +225,12 @@ describe(useQueueNotificationBanners, () => {
thumbnailUrls: ['https://example.com/preview.png']
})
} finally {
wrapper.unmount()
unmount()
}
})
it('shows one completion notification when history updates after queue becomes idle', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
vi.setSystemTime(4_000)
@@ -266,12 +266,12 @@ describe(useQueueNotificationBanners, () => {
await nextTick()
expect(composable.currentNotification.value).toBeNull()
} finally {
wrapper.unmount()
unmount()
}
})
it('queues both completed and failed notifications for mixed batches', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
await runBatch({
@@ -302,12 +302,12 @@ describe(useQueueNotificationBanners, () => {
count: 1
})
} finally {
wrapper.unmount()
unmount()
}
})
it('uses up to two completion thumbnails for notification icon previews', async () => {
const { wrapper, composable } = mountComposable()
const { unmount, composable } = mountComposable()
try {
await runBatch({
@@ -342,7 +342,7 @@ describe(useQueueNotificationBanners, () => {
]
})
} finally {
wrapper.unmount()
unmount()
}
})
})

View File

@@ -1,8 +1,7 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { formatPercent0 } from '@/utils/numberUtil'
@@ -32,19 +31,16 @@ vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => createExecutionStoreMock()
}))
const mountedWrappers: VueWrapper[] = []
const mountUseQueueProgress = () => {
let composable: ReturnType<typeof useQueueProgress>
const wrapper = mount({
render({
template: '<div />',
setup() {
composable = useQueueProgress()
return {}
}
})
mountedWrappers.push(wrapper)
return { wrapper, composable: composable! }
return { composable: composable! }
}
const setExecutionProgress = (value?: number | null) => {
@@ -62,10 +58,6 @@ describe('useQueueProgress', () => {
setExecutingNodeProgress(null)
})
afterEach(() => {
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount())
})
it.each([
{
description: 'defaults to 0% when execution store values are missing',

View File

@@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs

View File

@@ -1,10 +1,13 @@
import { flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
}
function createMockNode(
overrides: Partial<ComfyNodeDefImpl>
): ComfyNodeDefImpl {

View File

@@ -24,6 +24,8 @@ export interface PromotedWidgetView extends IBaseWidget {
* origin.
*/
readonly disambiguatingSourceNodeId?: string
/** Whether the resolved source widget is workflow-persistent. */
readonly sourceSerialize: boolean
}
export function isPromotedWidgetView(

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