Compare commits

..

18 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
195 changed files with 9242 additions and 7360 deletions

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,5 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
@@ -8,6 +9,31 @@ const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
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 remains usable after serialize and reload', async ({
comfyPage
}) => {
@@ -83,5 +109,35 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
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

@@ -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(

View File

@@ -77,6 +77,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
readonly serialize = false
/**
* Whether the resolved source widget is workflow-persistent.
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
* whose source sets serialize = false.
*/
get sourceSerialize(): boolean {
return this.resolveDeepest()?.widget.serialize !== false
}
last_y?: number
computedHeight?: number
@@ -149,13 +158,43 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.resolveDeepest()?.widget.linkedWidgets
}
private get _instanceKey(): string {
return this.disambiguatingSourceNodeId
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
: `${this.sourceNodeId}:${this.sourceWidgetName}`
}
get value(): IBaseWidget['value'] {
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
this._instanceKey
)
if (instanceValue !== undefined)
return instanceValue as IBaseWidget['value']
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
/**
* Execution-time serialization — returns the per-instance value stored
* during configure, falling back to the regular value getter.
*
* The widget state store is shared across instances (keyed by inner node
* ID), so the regular getter returns the last-configured value for all
* instances. graphToPrompt already prefers serializeValue over .value,
* so this is the hook that makes multi-instance execution correct.
*/
serializeValue(): IBaseWidget['value'] {
const v = this.subgraphNode._instanceWidgetValues.get(this._instanceKey)
if (v !== undefined) return v as IBaseWidget['value']
return this.value
}
set value(value: IBaseWidget['value']) {
// Keep per-instance map in sync for execution (graphToPrompt)
this.subgraphNode._instanceWidgetValues.set(this._instanceKey, value)
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()

View File

@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})

View File

@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
test('serialize stores widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -265,9 +265,7 @@ describe('Subgraph proxyWidgets', () => {
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toEqual(['value'])
})
test('serialize preserves proxyWidgets in properties', () => {

View File

@@ -0,0 +1,103 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
class TestCustomComboNode extends LGraphNode {
static override title = 'CustomCombo'
constructor() {
super('CustomCombo')
this.serialize_widgets = true
this.addOutput('value', '*')
this.addWidget('combo', 'value', '', () => {}, {
values: [] as string[]
})
}
}
function findWidget(node: LGraphNode, name: string) {
return node.widgets?.find((widget) => widget.name === name)
}
function getCustomWidgetsExtension(): ComfyExtension {
const extension = useExtensionStore().extensions.find(
(candidate) => candidate.name === 'Comfy.CustomWidgets'
)
if (!extension) {
throw new Error('Comfy.CustomWidgets extension was not registered')
}
return extension
}
describe('CustomCombo copy/paste', () => {
beforeAll(async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
await import('./customWidgets')
const extension = getCustomWidgetsExtension()
await extension.beforeRegisterNodeDef?.(
TestCustomComboNode,
{ name: 'CustomCombo' } as ComfyNodeDef,
app
)
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
})
afterAll(() => {
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
})
it('preserves combo options and selected value through clone and paste', () => {
const graph = new LGraph()
type AppWithRootGraph = { rootGraphInternal?: LGraph }
const appWithRootGraph = app as unknown as AppWithRootGraph
const previousRootGraph = appWithRootGraph.rootGraphInternal
appWithRootGraph.rootGraphInternal = graph
try {
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
graph.add(original)
findWidget(original, 'option1')!.value = 'alpha'
findWidget(original, 'option2')!.value = 'beta'
findWidget(original, 'option3')!.value = 'gamma'
findWidget(original, 'value')!.value = 'beta'
const clonedSerialised = original.clone()?.serialize()
expect(clonedSerialised).toBeDefined()
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
pasted.configure(clonedSerialised!)
graph.add(pasted)
expect(findWidget(pasted, 'value')!.value).toBe('beta')
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
expect(findWidget(pasted, 'value')!.options.values).toEqual([
'alpha',
'beta',
'gamma'
])
} finally {
appWithRootGraph.rootGraphInternal = previousRootGraph
}
})
})

View File

@@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) {
(w) => w.name.startsWith('option') && w.value
).map((w) => `${w.value}`)
)
if (app.configuringGraph) return
if (app.configuringGraph || !this.graph) return
if (values.includes(`${comboWidget.value}`)) return
comboWidget.value = values[0] ?? ''
comboWidget.callback?.(comboWidget.value)
@@ -71,6 +71,9 @@ function onCustomComboCreated(this: LGraphNode) {
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
this.applyToGraph!()
)
this.onAdded = useChainCallback(this.onAdded, function () {
updateCombo()
})
function addOption(node: LGraphNode) {
if (!node.widgets) return
@@ -78,16 +81,17 @@ function onCustomComboCreated(this: LGraphNode) {
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
Object.defineProperty(widget, 'value', {
get() {
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,

View File

@@ -186,11 +186,16 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
if (!widget) return
// Special case: SubgraphNode widget.
// Prefer serializeValue (per-instance) over the shared .value getter
// so multiple SubgraphNode instances return their own configured values.
const widgetValue = widget.serializeValue
? widget.serializeValue(subgraphNode, -1)
: widget.value
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: widget.value }
widgetInfo: { value: widgetValue }
}
}

View File

@@ -0,0 +1,166 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function createNodeWithWidget(
title: string,
widgetValue: unknown = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
// @ts-expect-error Abstract class instantiation
const widget = new BaseWidget({
name: 'widget',
type: 'number',
value: widgetValue,
y: 0,
options: { min: 0, max: 100, step: 1 },
node
})
node.widgets = [widget]
input.widget = { name: widget.name }
return { node, widget, input }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
})
instance2.configure({
id: 202,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1[0].value).toBe(10)
expect(widgets2[0].value).toBe(20)
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
expect(instance1.serialize().widgets_values).toEqual([10])
expect(instance2.serialize().widgets_values).toEqual([20])
})
it('round-trips per-instance widget values through serialize and configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
originalInstance.configure({
id: 301,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
restoredInstance.configure({
...serialized,
id: 302,
type: subgraph.id
})
const restoredWidget = restoredInstance.widgets?.[0]
expect(restoredWidget?.value).toBe(33)
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('skips non-serializable source widgets during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
// Mark the source widget as non-persistent (e.g. preview widget)
widget.serialize = false
const instance = createTestSubgraphNode(subgraph, { id: 501 })
instance.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: []
})
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
})

View File

@@ -993,7 +993,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
private _pendingWidgetsValues?: unknown[]
/**
* Per-instance promoted widget values.
* Multiple SubgraphNode instances share the same inner nodes, so
* promoted widget values must be stored per-instance to avoid collisions.
* Key: `${sourceNodeId}:${sourceWidgetName}`
*/
readonly _instanceWidgetValues = new Map<string, unknown>()
override configure(info: ExportedSubgraphInstance): void {
this._pendingWidgetsValues = info.widgets_values
for (const input of this.inputs) {
if (
input._listenerController &&
@@ -1124,6 +1137,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
}
// Hydrate per-instance promoted widget values from serialized data.
// LGraphNode.configure skips promoted widgets (serialize === false on
// the view), so they must be applied here after promoted views exist.
// Only iterate serializable views to match what serialize() wrote.
if (this._pendingWidgetsValues) {
const views = this._getPromotedViews()
let i = 0
for (const view of views) {
if (!view.sourceSerialize) continue
if (i >= this._pendingWidgetsValues.length) break
view.value = this._pendingWidgetsValues[i++] as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
/**
@@ -1573,28 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization.
// Only sync for inputs that are linked to a promoted widget via _widget.
for (const input of this.inputs) {
if (!input._widget) continue
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
}
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
@@ -1602,7 +1609,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
this.properties.proxyWidgets = this._serializeEntries(entries)
return super.serialize()
const serialized = super.serialize()
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
if (serializableViews.length > 0) {
serialized.widgets_values = serializableViews.map((view) => {
const value = view.serializeValue
? view.serializeValue(this, -1)
: view.value
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: (value ?? null)
})
}
return serialized
}
override clone() {
const clone = super.clone()

View File

@@ -1331,6 +1331,7 @@
"updating": "جارٍ التحديث",
"upload": "رفع",
"uploadAlreadyInProgress": "الرفع جارٍ بالفعل",
"uploadTimedOut": "انتهت مهلة التحميل. يرجى المحاولة مرة أخرى.",
"usageHint": "تلميح الاستخدام",
"use": "استخدم",
"user": "المستخدم",

View File

@@ -1331,6 +1331,7 @@
"updating": "Actualizando",
"upload": "Subir",
"uploadAlreadyInProgress": "La carga ya está en curso",
"uploadTimedOut": "La carga ha excedido el tiempo de espera. Por favor, inténtalo de nuevo.",
"usageHint": "Sugerencia de uso",
"use": "Usar",
"user": "Usuario",

View File

@@ -1331,6 +1331,7 @@
"updating": "در حال به‌روزرسانی {id}",
"upload": "بارگذاری",
"uploadAlreadyInProgress": "بارگذاری در حال انجام است",
"uploadTimedOut": "زمان بارگذاری به پایان رسید. لطفاً دوباره تلاش کنید.",
"usageHint": "راهنمای استفاده",
"use": "استفاده",
"user": "کاربر",

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