Compare commits

...

28 Commits

Author SHA1 Message Date
GitHub Action
a9e53b997c [automated] Apply ESLint and Oxfmt fixes 2026-03-27 13:50:45 +00:00
dante01yoon
3aef8da214 fix(test): drop fragile z-index assertion from tooltip e2e test
getComputedStyle().zIndex returns "auto" in CI because Tailwind CSS
z-[1700] is not resolved to a numeric value in the CI build environment.
2026-03-27 22:47:42 +09:00
dante01yoon
5b2f4b303a test: add e2e regression tests for BaseTooltip migration 2026-03-27 22:47:42 +09:00
dante01yoon
7bf2120cc5 fix: add stroke to tooltip arrow for visibility against same-color backgrounds
Arrow fill and action bar background are identical (rgb(23,23,24)),
making the arrow invisible. Add stroke-node-component-tooltip-border
to match the tooltip border and make the arrow visually distinct.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
dante01yoon
8cd39fc24f fix: use z-[1700] bracket syntax for tooltip z-index
z-1700 without brackets was not generating a CSS rule (evaluated as
z-index: auto). Use Tailwind arbitrary value syntax z-[1700] to
ensure the class is generated correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
dante01yoon
1065c3da23 fix: increase tooltip z-index to z-1700 for overlay visibility
Tooltips were hidden behind LiteGraphCanvasSplitterOverlay (z-999)
because TooltipPortal renders to <body> with only z-50. Align with
Popover content z-index (z-1700).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
dante01yoon
30e4b443d1 feat: align BaseTooltip with Figma design, add Storybook, fix as-child nesting
- Update tooltip variants: shadow-interface, leading-none, export FOR_STORIES
- Add keybind and showIcon props to BaseTooltip per Figma design spec
- Add comprehensive Storybook stories for all tooltip variants
- Fix Popover + BaseTooltip as-child nesting conflict in
  JobHistoryActionsMenu and JobFilterActions by moving BaseTooltip
  outside the Popover #button slot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:42 +09:00
Dante
77105602c0 refactor: migrate buildTooltipConfig consumers to BaseTooltip (#10378)
PR 2 in the tooltip migration series. Migrates all `buildTooltipConfig`
consumers from PrimeVue `v-tooltip` directive to the new Reka UI
`BaseTooltip` component.

- Replace `v-tooltip` + `buildTooltipConfig` with `<BaseTooltip>`
wrapper in 8 component files
- Remove computed wrappers that only existed for tooltip config
- Delete `src/composables/useTooltipConfig.ts` (no remaining consumers)
- Update 7 test files: replace tooltip directive stubs with
`BaseTooltip` component stubs

1. **PR 1** (base): Add `BaseTooltip` component using Reka UI
2. **PR 2** (this): Migrate `buildTooltipConfig` consumers (small
variant)
3. PR 3: Migrate remaining PrimeVue `v-tooltip` usages (Style 1)
4. PR 4: Remove PrimeVue tooltip directive registration

- [x] `pnpm typecheck` passes
- [x] `pnpm lint:fix` passes
- [x] `pnpm format` passes
- [x] `pnpm test:unit` passes (529 files, 7022 tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10378-refactor-migrate-buildTooltipConfig-consumers-to-BaseTooltip-32a6d73d3650812980c4d8b259fb502c)
by [Unito](https://www.unito.io)
2026-03-27 22:47:42 +09:00
dante01yoon
5e8146267a refactor: align tooltip variants with Figma design system
Replace variant prop (default/small/large) with size prop (small/large)
to match Figma component properties. Remove default variant that was
replicating PrimeVue Aura styling. Extract shared styles into cva base.
2026-03-27 22:47:42 +09:00
dante01yoon
f8e2a81666 refactor: remove Storybook stories from foundation PR 2026-03-27 22:47:42 +09:00
dante01yoon
d429a10ea6 feat: add BaseTooltip component using Reka UI
Add foundational tooltip component with three variants (default, small,
large), four positioning sides, and configurable delay/disabled state.
Wrap App.vue in TooltipProvider for app-wide tooltip support. Include
Storybook stories for all variants, sides, disabled, and long text.
2026-03-27 22:47:42 +09:00
Jin Yi
2f9431c6dd fix: stop Escape key propagation in Select components (#10397) 2026-03-27 18:50:04 +09:00
Christian Byrne
62979e3818 refactor: rename firebaseAuthStore to authStore with shared test fixtures (#10483)
## Summary

Rename `useFirebaseAuthStore` → `useAuthStore` and
`FirebaseAuthStoreError` → `AuthStoreError`. Introduce shared mock
factory (`authStoreMock.ts`) to replace 16 independent bespoke mocks.

## Changes

- **What**: Mechanical rename of store, composable, class, and store ID
(`firebaseAuth` → `auth`). Created
`src/stores/__tests__/authStoreMock.ts` — a shared mock factory with
reactive controls, used by all consuming test files. Migrated all 16
test files from ad-hoc mocks to the shared factory.
- **Files**: 62 files changed (rename propagation + new test infra)

## Review Focus

- Mock factory API design in `authStoreMock.ts` — covers all store
properties with reactive `controls` for per-test customization
- Self-test in `authStoreMock.test.ts` validates computed reactivity

Fixes #8219

## Stack

This is PR 1/5 in a stacked refactoring series:
1. **→ This PR**: Rename + shared test fixtures
2. #10484: Extract auth-routing from workspaceApi
3. #10485: Auth token priority tests
4. #10486: Decompose MembersPanelContent
5. #10487: Consolidate SubscriptionTier type

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-27 00:31:11 -07:00
Kelly Yang
6e249f2e05 fix: prevent canvas zoom when scrolling image history dropdown (#10550)
## Summary
 
Fix #10549 where using the mouse wheel over the image history dropdown
(e.g., in "Load Image" nodes) would trigger canvas zooming instead of
scrolling the list.

## Changes
Added `data-capture-wheel="true" ` to the root container. This attribute
is used by the `TransformPane` to identify elements that should consume
wheel events.

## Screenshots
 
after


https://github.com/user-attachments/assets/8935a1ca-9053-4ef1-9ab8-237f43eabb35

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10550-fix-prevent-canvas-zoom-when-scrolling-image-history-dropdown-32f6d73d365081c4ad09f763481ef8c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-27 06:43:24 +00:00
Jin Yi
a1c46d7086 fix: replace hardcoded font-size 10px/11px with text-2xs Tailwind token (#10604)
## Summary

Replace all hardcoded `text-[10px]`, `text-[11px]`, and `font-size:
10px` with a new `text-2xs` Tailwind theme token (0.625rem / 10px).

## Changes

- **What**: Add `--text-2xs` custom theme token to design system CSS and
replace 14 hardcoded font-size occurrences across 12 files with
`text-2xs`.

## Review Focus

Consistent use of design tokens instead of arbitrary values for small
font sizes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10604-fix-replace-hardcoded-font-size-10px-11px-with-text-2xs-Tailwind-token-3306d73d365081dfa1ebdc278e0a20b7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 23:35:05 -07:00
Benjamin Lu
dd89b74ca5 fix: wait for settings before cloud desktop promo (#10526)
this fixes two issues, setting store race did not await load, and it
only cleared shown on clear not on show

## Summary

Wait for settings to load before deciding whether to show the one-time
macOS desktop cloud promo so the persisted dismissal state is respected
on launch.

## Changes

- **What**: Await `settingStore.load()` before checking
`Comfy.Desktop.CloudNotificationShown`, keep the promo gated to macOS
desktop, and persist the shown flag before awaiting dialog close.
- **Dependencies**: None

## Review Focus

- Launch-time settings race for `Comfy.Desktop.CloudNotificationShown`
- One-time modal behavior if the app closes before the dialog is
dismissed
- Regression coverage in `src/App.test.ts`

## Screenshots (if applicable)

- N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10526-fix-wait-for-settings-before-cloud-desktop-promo-32e6d73d365081939fc3ca5b4346b873)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-26 22:31:31 -07:00
Christian Byrne
e809d74192 perf: disable Sentry event target wrapping to reduce DOM event overhead (#10472)
## Summary

Disable Sentry `browserApiErrorsIntegration` event target wrapping for
cloud builds to eliminate 231.7ms of `sentryWrapped` overhead during
canvas interaction.

## Changes

- **What**: Configure `browserApiErrorsIntegration({ eventTarget: false
})` in the cloud Sentry init path. This prevents Sentry from wrapping
every `addEventListener` callback in try/catch, which was the #1 hot
function during multi-cluster panning (100 profiling samples). Error
capturing still works via `window.onerror` and `unhandledrejection`.

## Review Focus

- Confirm that disabling event target wrapping is acceptable for cloud
error monitoring — Sentry still captures unhandled errors, just not
errors thrown inside individual event handler callbacks.
- Non-cloud builds already had `integrations: []` /
`defaultIntegrations: false`, so no change there.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10472-perf-disable-Sentry-event-target-wrapping-to-reduce-DOM-event-overhead-32d6d73d365081cdb455e47aee34dcc6)
by [Unito](https://www.unito.io)
2026-03-26 22:06:47 -07:00
Kelly Yang
47c9a027a7 fix: use try/finally for loading state in TeamWorkspacesDialogContent… (#10601)
… onCreate

## Summary
Wrap the function body in try/finally in the
`src/platform/workspace/components/dialogs/TeamWorkspacesDialogContent.vue`
to avoid staying in a permanent loading state if an unexpected error
happens.

Fix #10458 

```
async function onCreate() {
  if (!isValidName.value || loading.value) return
  loading.value = true

  try {
    const name = workspaceName.value.trim()
    try {
      await workspaceStore.createWorkspace(name)
    } catch (error) {
      toast.add({
        severity: 'error',
        summary: t('workspacePanel.toast.failedToCreateWorkspace'),
        detail: error instanceof Error ? error.message : t('g.unknownError')
      })
      return
    }
    try {
      await onConfirm?.(name)
    } catch (error) {
      toast.add({
        severity: 'error',
        summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
        detail: error instanceof Error ? error.message : t('g.unknownError')
      })
    }
    dialogStore.closeDialog({ key: DIALOG_KEY })
  } finally {
    loading.value = false
  }
}
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10601-fix-use-try-finally-for-loading-state-in-TeamWorkspacesDialogContent-3306d73d365081dcb97bf205d7be9ca7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 21:38:11 -07:00
Christian Byrne
6fbc5723bd fix: hide inaccurate resolution subtitle on cloud asset cards (#10602)
## Summary

Hide image resolution subtitle on cloud asset cards because thumbnails
are downscaled to max 512px, causing `naturalWidth`/`naturalHeight` to
report incorrect dimensions.

## Changes

- **What**: Gate the dimension display in `MediaAssetCard.vue` behind
`!isCloud` so resolution is only shown on local (where full-res images
are loaded). Added TODO referencing #10590 for re-enabling once
`/assets` API returns original dimensions in metadata.

## Review Focus

One-line conditional change — the `isCloud` import from
`@/platform/distribution/types` follows the established pattern used
across the repo.

Fixes #10590

## Screenshots (if applicable)

N/A — this removes a subtitle that was displaying wrong values (e.g.,
showing 512x512 for a 1024x1024 image on cloud).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10602-fix-hide-inaccurate-resolution-subtitle-on-cloud-asset-cards-3306d73d36508186bd3ad704bd83bf14)
by [Unito](https://www.unito.io)
2026-03-27 13:32:11 +09:00
Benjamin Lu
db6e5062f2 test: add assets sidebar empty-state coverage (#10595)
## Summary

Add the first user-centric Playwright coverage for the assets sidebar
empty state and introduce a small assets-specific test helper/page
object surface.

## Changes

- **What**: add `AssetsSidebarTab`, add `AssetsHelper`, and cover
generated/imported empty states in a dedicated browser spec

## Review Focus

This is intentionally a small first slice for assets-sidebar coverage.
The new helper still mocks the HTTP boundary in Playwright for now
because current OSS job history and input files are global backend
state, which makes true backend-seeded parallel coverage a separate
backend change.

Long-term recommendation: add backend-owned, user-scoped test seeding
for jobs/history and input assets so browser tests can hit the real
routes on a shared backend. Follow-up: COM-307.

Fixes COM-306

## Screenshots (if applicable)

Not applicable.

## Validation

- `pnpm typecheck:browser`
- `pnpm exec playwright test browser_tests/tests/sidebar/assets.spec.ts
--project=chromium` against an isolated preview env

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10595-test-add-assets-sidebar-empty-state-coverage-3306d73d365081d1b34fdd146ae6c5c6)
by [Unito](https://www.unito.io)
2026-03-26 21:19:38 -07:00
Terry Jia
6da5d26980 test: add painter widget e2e tests (#10599)
## Summary
add painter widget e2e tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10599-test-add-painter-widget-e2e-tests-3306d73d365081899b3ec3e1d7c6f57c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-27 00:19:17 -04:00
Johnpaul Chiwetelu
9b6b762a97 test: add browser tests for zoom controls (#10589)
## Summary
- Add E2E Playwright tests for zoom controls: default zoom level, zoom
to fit, zoom out with clamping at 10% minimum, manual percentage input,
and toggle visibility
- Add `data-testid` attributes to `ZoomControlsModal.vue` for stable
test selectors
- Add new TestId entries to `selectors.ts`

## Test plan
- [x] All 6 new tests pass locally
- [x] Existing minimap and graphCanvasMenu tests still pass
- [ ] CI passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10589-test-add-browser-tests-for-zoom-controls-3306d73d36508177ae19e16b3f62b8e7)
by [Unito](https://www.unito.io)
2026-03-27 05:18:46 +01:00
Kelly Yang
00c8c11288 fix: derive payment redirect URLs from getComfyPlatformBaseUrl() (#10600)
Replaces hardcoded `https://www.comfy.org/payment/...` URLs with
`getComfyPlatformBaseUrl()` so staging deploys no longer redirect users
to production after payment.

fix #10456

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10600-fix-derive-payment-redirect-URLs-from-getComfyPlatformBaseUrl-3306d73d365081679ef4da840337bb81)
by [Unito](https://www.unito.io)
2026-03-26 20:51:07 -07:00
Comfy Org PR Bot
668f7e48e7 1.43.7 (#10583)
Patch version increment to 1.43.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10583-1-43-7-3306d73d3650810f921bf97fc447e402)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-26 19:56:32 -07:00
Alexander Brown
3de387429a test: migrate 11 interactive component tests from VTU to VTL (Phase 2) (#10490)
## Summary

Phase 2 of the VTL migration: migrate 11 interactive component tests
from @vue/test-utils to @testing-library/vue (69 tests).

Stacked on #10471.

## Changes

- **What**: Migrate BatchCountEdit, BypassButton, BuilderFooterToolbar,
ComfyActionbar, SidebarIcon, EditableText, UrlInput, SearchInput,
TagsInput, TreeExplorerTreeNode, ColorCustomizationSelector from VTU to
VTL
- **Pattern transforms**: `trigger('click')` → `userEvent.click()`,
`setValue()` → `userEvent.type()`, `findComponent().props()` →
`getByRole/getByText/getByTestId`, `emitted()` → callback props
- **Removed**: 4 `@ts-expect-error` annotations, 1 change-detector test
(SearchInput `vm.focus`)
- **PrimeVue**: `data-pc-name` selectors + `aria-pressed` for
SelectButton, container queries for ColorPicker/InputIcon

## Review Focus

- PrimeVue escape hatches in ColorCustomizationSelector
(SelectButton/ColorPicker lack standard ARIA roles)
- Teleport test in ComfyActionbar uses `container.querySelector`
intentionally (scoped to teleport target)
- SearchInput debounce tests use `fireEvent.update` instead of
`userEvent.type` due to fake timer interaction
- EditableText escape-then-blur test simplified:
`userEvent.keyboard('{Escape}')` already triggers blur internally

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10490-test-migrate-11-interactive-component-tests-from-VTU-to-VTL-Phase-2-32e6d73d3650817ca40fd61395499e3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-26 19:46:31 -07:00
Alexander Brown
08b1199265 test: migrate 13 component tests from VTU to VTL (Phase 1) (#10471)
## Summary

Migrate 13 component test files from @vue/test-utils to
@testing-library/vue as Phase 1 of incremental VTL adoption.

## Changes

- **What**: Rewrite 13 test files (88 tests) to use `render`/`screen`
queries, `userEvent` interactions, and `jest-dom` assertions. Add
`data-testid` attributes to 6 components for lint-clean icon/element
queries. Delete unused `src/utils/test-utils.ts`.
- **Dependencies**: `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom` (installed in
Phase 0)

## Review Focus

- `data-testid` additions to component templates are minimal and
non-behavioral
- PrimeVue passthrough (`pt`) usage in UserAvatar.vue for icon testid
- 2 targeted `eslint-disable` in FormRadioGroup.test.ts where PrimeVue
places `aria-describedby` on wrapper div, not input

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10471-test-migrate-13-component-tests-from-VTU-to-VTL-Phase-1-32d6d73d36508159a33ffa285afb4c38)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 18:15:11 -07:00
Dante
98a9facc7d tool: add CSS containment audit skill and Playwright diagnostic (#10026)
## Summary

- Add a Playwright-based diagnostic tool (`@audit` tagged) that
automatically detects DOM elements where CSS `contain: layout style`
would improve rendering performance
- Extend `ComfyPage` fixture and `playwright.config.ts` to support
`@audit` tag (excluded from CI, perf infra enabled)
- Add `/contain-audit` skill definition documenting the workflow

## How it works

1. Loads the 245-node workflow in a real browser
2. Walks the DOM tree and scores every element by subtree size and
sizing constraints
3. For each high-scoring candidate, applies `contain: layout style` via
JS
4. Measures rendering performance (style recalcs, layouts, task
duration) before and after
5. Takes before/after screenshots to detect visual breakage
6. Outputs a ranked report to console

## Test plan

- [ ] `pnpm typecheck` passes
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm lint` passes
- [ ] Existing Playwright tests unaffected (`@audit` excluded from CI
via `grepInvert`)
- [ ] Run `pnpm exec playwright test
browser_tests/tests/containAudit.spec.ts --project=chromium` locally
with dev server

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10026-tool-add-CSS-containment-audit-skill-and-Playwright-diagnostic-3256d73d365081b29470df164f798f7d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:08:41 +09:00
jaeone94
8b1cf594d1 fix: improve error overlay design and indicator placement (#10564)
## Summary

- Move error red border from TopMenuSection/ComfyActionbar to
ErrorOverlay
- Add error indicator (outline + StatusBadge dot) on right side panel
toggle button when errors are present, the panel/overlay are closed, and
the errors tab setting is enabled
- Replace technical group titles (e.g. "Missing Node Packs") with
user-friendly i18n messages in ErrorOverlay
- Dynamically change action button label based on single error type
(e.g. "Show missing nodes" instead of "See Errors")
- Remove unused `hasAnyError` prop from ComfyActionbar
- Fix `type="secondary"` → `variant="secondary"` on panel toggle button
- Pre-wire `missing_media` error type support for #10309
- Migrate ErrorOverlay E2E selectors from `getByText`/`getByRole` to
`data-testid`
- Update E2E screenshot snapshots affected by TopMenuSection error state
design changes

## Test plan

- [x] Trigger execution error → verify red border on ErrorOverlay, no
red border on TopMenuSection/ComfyActionbar
- [x] With errors and right side panel closed → verify red outline + dot
on panel toggle button
- [x] Open right side panel or error overlay → verify indicator
disappears
- [x] Disable `Comfy.RightSidePanel.ShowErrorsTab` → verify no indicator
even with errors
- [x] Load workflow with only missing nodes → verify "Show missing
nodes" button label and friendly message
- [x] Load workflow with only missing models → verify "Show missing
models" button label and count message
- [x] Load workflow with mixed errors → verify "See Errors" fallback
label
- [x] E2E: `pnpm test:browser:local -- --grep "Error overlay"`

## Screenshots
<img width="498" height="381" alt="스크린샷 2026-03-26 230252"
src="https://github.com/user-attachments/assets/034f0f3f-e6a1-4617-b8f6-cd4c145e3a47"
/>
<img width="550" height="303" alt="스크린샷 2026-03-26 230525"
src="https://github.com/user-attachments/assets/2958914b-0ff0-461b-a6ea-7f2811bf33c2"
/>
<img width="551" height="87" alt="스크린샷 2026-03-26 230318"
src="https://github.com/user-attachments/assets/396e9cb1-667e-44c4-83fe-ab113b313d16"
/>

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Dante <bunggl@naver.com>
2026-03-27 09:11:26 +09:00
175 changed files with 3584 additions and 1910 deletions

View File

@@ -0,0 +1,99 @@
---
name: contain-audit
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
---
# CSS Containment Audit
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
## What It Does
1. Loads a large workflow (245 nodes) in a real browser
2. Walks the DOM tree and scores every element as a containment candidate
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
5. Takes before/after screenshots to detect visual breakage
6. Generates a ranked report with actionable recommendations
## When to Use
- After adding new Vue components to the node rendering pipeline
- When investigating rendering performance on large workflows
- Before and after refactoring node DOM structure
- As part of periodic performance audits
## How to Run
```bash
# Start the dev server first
pnpm dev &
# Run the audit (uses the @audit tag, not included in normal CI runs)
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
# View the HTML report
pnpm exec playwright show-report
```
## How to Read Results
The audit outputs a table to the console:
```text
CSS Containment Audit Results
=======================================================
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
2 | .node-body | 12 | 48 | -8% | -3% | OK
3 | .node-header | 4 | 16 | +1% | 0% | OK
```
- **Subtree**: Number of descendant elements (higher = more to skip)
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
## Candidate Scoring
An element is a good containment candidate when:
1. **Large subtree** -- many descendants that the browser can skip recalculating
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
3. **No existing containment** -- `contain` is not already applied
4. **Not a leaf** -- has at least a few child elements
Elements that should NOT get containment:
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
- Elements whose height is determined by content and affects sibling layout
- Very small subtrees (overhead of containment context outweighs benefit)
## Limitations
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
- Performance measurements have natural variance; run multiple times for confidence
- Only tests idle and pan scenarios; widget interactions may differ
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
## Example PR
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
## Reference
| Resource | Path |
| ----------------- | ------------------------------------------------------- |
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf tests | `browser_tests/tests/performance.spec.ts` |
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |

View File

@@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
3. Keep your module mocks contained
Do not use global mutable state within the test file
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
5. Aim for behavioral coverage of critical and new features
### Playwright / Browser / E2E Tests

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Painter",
"pos": [50, 50],
"size": [450, 550],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -19,10 +19,12 @@ import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
@@ -55,6 +57,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -78,6 +81,11 @@ class ComfyMenu {
return this._nodeLibraryTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
}
get workflowsTab() {
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
return this._workflowsTab
@@ -192,6 +200,7 @@ export class ComfyPage {
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
@@ -238,6 +247,7 @@ export class ComfyPage {
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}
@@ -452,12 +462,13 @@ export const comfyPageFixture = base.extend<{
await comfyPage.setup()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
const needsPerf =
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
if (needsPerf) await comfyPage.perf.init()
await use(comfyPage)
if (isPerf) await comfyPage.perf.dispose()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -168,3 +168,32 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')
}
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
)
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
override async open() {
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
}

View File

@@ -0,0 +1,147 @@
import type { Page, Route } from '@playwright/test'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {}
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = [...this.generatedJobs]
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
}
}

View File

@@ -20,7 +20,12 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
zoomOutAction: 'zoom-out-action',
zoomToFitAction: 'zoom-to-fit-action',
zoomPercentageInput: 'zoom-percentage-input'
},
dialogs: {
settings: 'settings-dialog',
@@ -29,6 +34,8 @@ export const TestIds = {
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
errorOverlayDismiss: 'error-overlay-dismiss',
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',

View File

@@ -0,0 +1,47 @@
import type { Locator, Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
function tooltipLocator(page: Page): Locator {
return page.locator('[role="tooltip"]')
}
async function hoverAway(page: Page): Promise<void> {
await page.mouse.move(0, 0)
}
test.describe('BaseTooltip regression', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Queue history button shows tooltip on hover', async ({ comfyPage }) => {
const queueButton = comfyPage.page.getByTestId('queue-overlay-toggle')
await queueButton.hover()
const tooltip = tooltipLocator(comfyPage.page)
await expect(tooltip).toBeVisible()
await hoverAway(comfyPage.page)
await expect(tooltip).not.toBeVisible()
})
test('Toggle properties panel button shows tooltip on hover', async ({
comfyPage
}) => {
const panelButton = comfyPage.page
.getByLabel(/Toggle properties panel/i)
.first()
await panelButton.hover()
const tooltip = tooltipLocator(comfyPage.page)
await expect(tooltip).toBeVisible()
await hoverAway(comfyPage.page)
await expect(tooltip).not.toBeVisible()
})
})

View File

@@ -0,0 +1,276 @@
import { expect } from '@playwright/test'
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface ContainCandidate {
selector: string
testId: string | null
tagName: string
className: string
subtreeSize: number
hasFixedWidth: boolean
isFlexChild: boolean
hasExplicitDimensions: boolean
alreadyContained: boolean
score: number
}
interface AuditResult {
candidate: ContainCandidate
baseline: Pick<PerfMeasurement, 'styleRecalcs' | 'layouts' | 'taskDurationMs'>
withContain: Pick<
PerfMeasurement,
'styleRecalcs' | 'layouts' | 'taskDurationMs'
>
deltaRecalcsPct: number
deltaLayoutsPct: number
visuallyBroken: boolean
}
function formatPctDelta(value: number): string {
const sign = value >= 0 ? '+' : ''
return `${sign}${value.toFixed(1)}%`
}
function pctChange(baseline: number, measured: number): number {
if (baseline === 0) return 0
return ((measured - baseline) / baseline) * 100
}
const STABILIZATION_FRAMES = 60
const SETTLE_FRAMES = 10
test.describe('CSS Containment Audit', { tag: ['@audit'] }, () => {
test('scan large graph for containment candidates', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
await comfyPage.nextFrame()
}
// Walk the DOM and find candidates
const candidates = await comfyPage.page.evaluate((): ContainCandidate[] => {
const results: ContainCandidate[] = []
const graphContainer =
document.querySelector('.graph-canvas-container') ??
document.querySelector('[class*="comfy-vue-node"]')?.parentElement ??
document.querySelector('.lg-node')?.parentElement
const root = graphContainer ?? document.body
const allElements = root.querySelectorAll('*')
allElements.forEach((el) => {
if (!(el instanceof HTMLElement)) return
const subtreeSize = el.querySelectorAll('*').length
if (subtreeSize < 5) return
const computed = getComputedStyle(el)
const containValue = computed.contain || 'none'
const alreadyContained =
containValue.includes('layout') || containValue.includes('strict')
const hasFixedWidth =
computed.width !== 'auto' &&
!computed.width.includes('%') &&
computed.width !== '0px'
const isFlexChild =
el.parentElement !== null &&
getComputedStyle(el.parentElement).display.includes('flex') &&
(computed.flexGrow !== '0' || computed.flexShrink !== '1')
const hasExplicitDimensions =
hasFixedWidth ||
(computed.minWidth !== '0px' && computed.minWidth !== 'auto') ||
(computed.maxWidth !== 'none' && computed.maxWidth !== '0px')
let score = subtreeSize
if (hasExplicitDimensions) score *= 2
if (isFlexChild) score *= 1.5
if (alreadyContained) score = 0
let selector = el.tagName.toLowerCase()
const testId = el.getAttribute('data-testid')
if (testId) {
selector = `[data-testid="${testId}"]`
} else if (el.id) {
selector = `#${el.id}`
} else if (el.parentElement) {
// Use nth-child to disambiguate instead of fragile first-class fallback
// (e.g. Tailwind utilities like .flex, .relative are shared across many elements)
const children = Array.from(el.parentElement.children)
const index = children.indexOf(el) + 1
const parentTestId = el.parentElement.getAttribute('data-testid')
if (parentTestId) {
selector = `[data-testid="${parentTestId}"] > :nth-child(${index})`
} else if (el.parentElement.id) {
selector = `#${el.parentElement.id} > :nth-child(${index})`
} else {
const tag = el.tagName.toLowerCase()
selector = `${tag}:nth-child(${index})`
}
}
results.push({
selector,
testId,
tagName: el.tagName.toLowerCase(),
className:
typeof el.className === 'string' ? el.className.slice(0, 80) : '',
subtreeSize,
hasFixedWidth,
isFlexChild,
hasExplicitDimensions,
alreadyContained,
score
})
})
results.sort((a, b) => b.score - a.score)
return results.slice(0, 20)
})
console.log(`\nFound ${candidates.length} containment candidates\n`)
// Deduplicate candidates by selector (keep highest score)
const seen = new Set<string>()
const uniqueCandidates = candidates.filter((c) => {
if (seen.has(c.selector)) return false
seen.add(c.selector)
return true
})
// Measure baseline performance (idle)
await comfyPage.perf.startMeasuring()
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
await comfyPage.nextFrame()
}
const baseline = await comfyPage.perf.stopMeasuring('baseline-idle')
// Take a baseline screenshot for visual comparison
const baselineScreenshot = await comfyPage.page.screenshot()
// For each candidate, apply contain and measure
const results: AuditResult[] = []
const testCandidates = uniqueCandidates
.filter((c) => !c.alreadyContained && c.score > 0)
.slice(0, 10)
for (const candidate of testCandidates) {
const applied = await comfyPage.page.evaluate((sel: string) => {
const elements = document.querySelectorAll(sel)
let count = 0
elements.forEach((el) => {
if (el instanceof HTMLElement) {
el.style.contain = 'layout style'
count++
}
})
return count
}, candidate.selector)
if (applied === 0) continue
for (let i = 0; i < SETTLE_FRAMES; i++) {
await comfyPage.nextFrame()
}
// Measure with containment
await comfyPage.perf.startMeasuring()
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
await comfyPage.nextFrame()
}
const withContain = await comfyPage.perf.stopMeasuring(
`contain-${candidate.selector}`
)
// Take screenshot with containment applied to detect visual breakage.
// Note: PNG byte comparison can produce false positives from subpixel
// rendering and anti-aliasing. Treat "DIFF" as "needs manual review".
const containScreenshot = await comfyPage.page.screenshot()
const visuallyBroken = !baselineScreenshot.equals(containScreenshot)
// Remove containment
await comfyPage.page.evaluate((sel: string) => {
document.querySelectorAll(sel).forEach((el) => {
if (el instanceof HTMLElement) {
el.style.contain = ''
}
})
}, candidate.selector)
for (let i = 0; i < SETTLE_FRAMES; i++) {
await comfyPage.nextFrame()
}
results.push({
candidate,
baseline: {
styleRecalcs: baseline.styleRecalcs,
layouts: baseline.layouts,
taskDurationMs: baseline.taskDurationMs
},
withContain: {
styleRecalcs: withContain.styleRecalcs,
layouts: withContain.layouts,
taskDurationMs: withContain.taskDurationMs
},
deltaRecalcsPct: pctChange(
baseline.styleRecalcs,
withContain.styleRecalcs
),
deltaLayoutsPct: pctChange(baseline.layouts, withContain.layouts),
visuallyBroken
})
}
// Print the report
const divider = '='.repeat(100)
const thinDivider = '-'.repeat(100)
console.log('\n')
console.log('CSS Containment Audit Results')
console.log(divider)
console.log(
'Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual'
)
console.log(thinDivider)
results
.sort((a, b) => a.deltaRecalcsPct - b.deltaRecalcsPct)
.forEach((r, i) => {
const sel = r.candidate.selector.padEnd(42)
const sub = String(r.candidate.subtreeSize).padStart(7)
const score = String(Math.round(r.candidate.score)).padStart(5)
const dr = formatPctDelta(r.deltaRecalcsPct)
const dl = formatPctDelta(r.deltaLayoutsPct)
const vis = r.visuallyBroken ? 'DIFF' : 'OK'
console.log(
` ${String(i + 1).padStart(2)} | ${sel} | ${sub} | ${score} | ${dr.padStart(10)} | ${dl.padStart(10)} | ${vis}`
)
})
console.log(divider)
console.log(
`\nBaseline: ${baseline.styleRecalcs} style recalcs, ${baseline.layouts} layouts, ${baseline.taskDurationMs.toFixed(1)}ms task duration\n`
)
const alreadyContained = uniqueCandidates.filter((c) => c.alreadyContained)
if (alreadyContained.length > 0) {
console.log('Already contained elements:')
alreadyContained.forEach((c) => {
console.log(` ${c.selector} (subtree: ${c.subtreeSize})`)
})
}
expect(results.length).toBeGreaterThan(0)
})
// Pan interaction perf measurement removed — covered by PR #10001 (performance.spec.ts).
// The containment fix itself is tracked in PR #9946.
})

View File

@@ -28,8 +28,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing.*installed/i)
})
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
@@ -42,8 +45,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing.*installed/i)
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
@@ -102,7 +108,7 @@ test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
await expect(errorOverlay).toBeVisible()
// Dismiss the error overlay
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(errorOverlay).not.toBeVisible()
// Make a change to the graph by moving a node
@@ -210,8 +216,11 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/required model.*missing/i)
})
test('Should show missing models from node properties', async ({
@@ -226,8 +235,11 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/required model.*missing/i)
})
test('Should not show missing models when widget values have changed', async ({
@@ -240,7 +252,9 @@ test.describe('Missing models in Error Tab', () => {
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -4,6 +4,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -29,14 +30,14 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
@@ -44,10 +45,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -55,10 +56,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
})
@@ -68,10 +69,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
}) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /Dismiss/i }).click()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
@@ -82,7 +83,7 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -24,16 +25,14 @@ test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.getByRole('button', { name: 'Dismiss' })
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.waitFor({ state: 'hidden' })
await errorOverlay.waitFor({ state: 'hidden' })
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,92 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Painter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Renders canvas and controls',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
const painterWidget = node.locator('.widget-expands')
await expect(painterWidget).toBeVisible()
await expect(painterWidget.locator('canvas')).toBeVisible()
await expect(painterWidget.getByText('Brush')).toBeVisible()
await expect(painterWidget.getByText('Eraser')).toBeVisible()
await expect(painterWidget.getByText('Clear')).toBeVisible()
await expect(
painterWidget.locator('input[type="color"]').first()
).toBeVisible()
await expect(node).toHaveScreenshot('painter-default-state.png')
}
)
test(
'Drawing a stroke changes the canvas',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const canvas = node.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
const isEmptyBefore = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return true
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
})
expect(isEmptyBefore).toBe(true)
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
await comfyPage.page.mouse.move(
box.x + box.width * 0.3,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
box.x + box.width * 0.7,
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect(async () => {
const hasContent = await canvas.evaluate((el) => {
const ctx = (el as HTMLCanvasElement).getContext('2d')
if (!ctx) return false
const data = ctx.getImageData(
0,
0,
(el as HTMLCanvasElement).width,
(el as HTMLCanvasElement).height
)
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) return true
}
return false
})
expect(hasContent).toBe(true)
}).toPass()
await expect(node).toHaveScreenshot('painter-after-stroke.png')
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Assets sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
await tab.importedTab.click()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
})

View File

@@ -247,7 +247,7 @@ test.describe('Workflows sidebar', () => {
await expect(errorOverlay).toBeVisible()
// Dismiss the error overlay
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(errorOverlay).not.toBeVisible()
// Load blank workflow

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,138 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Zoom Controls', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
})
test('Default zoom is 100% and node has a size', async ({ comfyPage }) => {
const nodeSize = await comfyPage.page.evaluate(
() => window.app!.graph.nodes[0].size
)
expect(nodeSize[0]).toBeGreaterThan(0)
expect(nodeSize[1]).toBeGreaterThan(0)
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await expect(zoomButton).toContainText('100%')
const scale = await comfyPage.canvasOps.getScale()
expect(scale).toBeCloseTo(1.0, 1)
})
test('Zoom to fit reduces percentage', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
await expect(zoomToFit).toBeVisible()
await zoomToFit.click()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeLessThan(1.0)
await expect(zoomButton).not.toContainText('100%')
})
test('Zoom out reduces percentage', async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
await zoomOut.click()
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
for (let i = 0; i < 30; i++) {
await zoomOut.click()
}
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(0.1, 1)
await expect(zoomButton).toContainText('10%')
})
test('Manual percentage entry allows zoom in and zoom out', async ({
comfyPage
}) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const input = comfyPage.page
.getByTestId(TestIds.canvas.zoomPercentageInput)
.locator('input')
await input.focus()
await comfyPage.page.keyboard.press('Control+a')
await input.pressSequentially('100')
await input.press('Enter')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(1.0, 1)
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
await zoomIn.click()
await comfyPage.nextFrame()
const scaleAfterZoomIn = await comfyPage.canvasOps.getScale()
expect(scaleAfterZoomIn).toBeGreaterThan(1.0)
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
await zoomOut.click()
await comfyPage.nextFrame()
const scaleAfterZoomOut = await comfyPage.canvasOps.getScale()
expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn)
})
test('Clicking zoom button toggles zoom controls visibility', async ({
comfyPage
}) => {
const zoomButton = comfyPage.page.getByTestId(
TestIds.canvas.zoomControlsButton
)
await zoomButton.click()
await comfyPage.nextFrame()
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
await expect(zoomToFit).toBeVisible()
await zoomButton.click()
await comfyPage.nextFrame()
await expect(zoomToFit).not.toBeVisible()
})
})

View File

@@ -29,7 +29,9 @@ The ComfyUI Frontend project uses **colocated tests** - test files are placed al
Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started

View File

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

View File

@@ -25,6 +25,9 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);

View File

@@ -36,7 +36,7 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
grepInvert: /@mobile|@perf|@audit/ // Run all tests except those tagged with @mobile, @perf, or @audit
},
{
@@ -50,6 +50,17 @@ export default defineConfig({
fullyParallel: false
},
{
name: 'audit',
use: {
...devices['Desktop Chrome'],
trace: 'retain-on-failure'
},
timeout: 120_000,
grep: /@audit/,
fullyParallel: false
},
{
name: 'chromium-2x',
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 2 },

View File

@@ -1,23 +1,24 @@
<template>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<TooltipProvider :delay-duration="300" disable-hoverable-content>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</TooltipProvider>
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { TooltipProvider } from 'reka-ui'
import { computed, onMounted, watch } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const workspaceStore = useWorkspaceStore()
@@ -129,26 +130,5 @@ onMounted(() => {
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
// Show cloud notification for macOS desktop users (one-time)
if (isDesktop && electronAPI()?.getPlatform() === 'darwin') {
const settingStore = useSettingStore()
if (!settingStore.get('Comfy.Desktop.CloudNotificationShown')) {
const dialogService = useDialogService()
cloudNotificationTimer = setTimeout(async () => {
try {
await dialogService.showCloudNotification()
} catch (e) {
console.warn('[CloudNotification] Failed to show', e)
}
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
}, 2000)
}
}
})
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
onUnmounted(() => {
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
})
</script>

View File

@@ -71,8 +71,8 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))
@@ -119,6 +119,7 @@ function createWrapper({
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
@@ -131,9 +132,6 @@ function createWrapper({
template: '<div />'
},
...stubs
},
directives: {
tooltip: () => {}
}
}
})

View File

@@ -16,22 +16,23 @@
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
:aria-label="t('menu.manageExtensions')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[comfy--extensions-blocks] size-4" />
<span class="not-md:hidden">
{{ t('menu.manageExtensions') }}
</span>
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
<BaseTooltip :text="t('menu.manageExtensions')" side="bottom">
<Button
variant="secondary"
:aria-label="t('menu.manageExtensions')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[comfy--extensions-blocks] size-4" />
<span class="not-md:hidden">
{{ t('menu.manageExtensions') }}
</span>
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</BaseTooltip>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
@@ -46,7 +47,6 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -54,29 +54,50 @@
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
<BaseTooltip
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
:text="t('actionbar.shareTooltip')"
side="bottom"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<Button
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
</BaseTooltip>
<div v-if="!isRightSidePanelOpen" class="relative">
<BaseTooltip
:text="t('rightSidePanel.togglePanel')"
side="bottom"
>
<Button
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</BaseTooltip>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
</div>
</div>
<ErrorOverlay />
@@ -129,11 +150,12 @@ import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -206,12 +228,7 @@ const actionbarContainerClass = computed(() => {
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
@@ -243,24 +260,27 @@ const inlineProgressSummaryTarget = computed(() => {
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const shareTooltipConfig = computed(() =>
buildTooltipConfig(t('actionbar.shareTooltip'))
)
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionErrorStore)
const { hasAnyError, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const isErrorsTabEnabled = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const showErrorIndicatorOnPanelButton = computed(
() =>
isErrorsTabEnabled.value &&
hasAnyError.value &&
!isRightSidePanelOpen.value &&
!isErrorOverlayOpen.value
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()

View File

@@ -1,7 +1,7 @@
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 { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useQueueSettingsStore } from '@/stores/queueStore'
@@ -33,7 +33,7 @@ const i18n = createI18n({
}
})
function createWrapper(initialBatchCount = 1) {
function renderComponent(initialBatchCount = 1) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
@@ -44,7 +44,9 @@ function createWrapper(initialBatchCount = 1) {
}
})
const wrapper = mount(BatchCountEdit, {
const user = userEvent.setup()
render(BatchCountEdit, {
global: {
plugins: [pinia, i18n],
directives: {
@@ -55,44 +57,42 @@ function createWrapper(initialBatchCount = 1) {
const queueSettingsStore = useQueueSettingsStore()
return { wrapper, queueSettingsStore }
return { user, queueSettingsStore }
}
describe('BatchCountEdit', () => {
it('doubles the current batch count when increment is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(3)
const { user, queueSettingsStore } = renderComponent(3)
await wrapper.get('button[aria-label="Increment"]').trigger('click')
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(queueSettingsStore.batchCount).toBe(6)
})
it('halves the current batch count when decrement is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(9)
const { user, queueSettingsStore } = renderComponent(9)
await wrapper.get('button[aria-label="Decrement"]').trigger('click')
await user.click(screen.getByRole('button', { name: 'Decrement' }))
expect(queueSettingsStore.batchCount).toBe(4)
})
it('clamps typed values to queue limits on blur', async () => {
const { wrapper, queueSettingsStore } = createWrapper(2)
const input = wrapper.get('input')
const { user, queueSettingsStore } = renderComponent(2)
const input = screen.getByRole('textbox', { name: 'Batch Count' })
await input.setValue('999')
await input.trigger('blur')
await nextTick()
await user.clear(input)
await user.type(input, '999')
await user.tab()
expect(queueSettingsStore.batchCount).toBe(maxBatchCount)
expect((input.element as HTMLInputElement).value).toBe(
String(maxBatchCount)
)
expect(input).toHaveValue(String(maxBatchCount))
await input.setValue('0')
await input.trigger('blur')
await nextTick()
await user.clear(input)
await user.type(input, '0')
await user.tab()
expect(queueSettingsStore.batchCount).toBe(1)
expect((input.element as HTMLInputElement).value).toBe('1')
expect(input).toHaveValue('1')
})
})

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -20,15 +20,15 @@ const configureSettings = (
})
}
const mountActionbar = (showRunProgressBar: boolean) => {
const renderActionbar = (showRunProgressBar: boolean) => {
const topMenuContainer = document.createElement('div')
document.body.appendChild(topMenuContainer)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, showRunProgressBar)
const wrapper = mount(ComfyActionbar, {
attachTo: document.body,
render(ComfyActionbar, {
container: document.body.appendChild(document.createElement('div')),
props: {
topMenuContainer,
queueOverlayExpanded: false
@@ -36,6 +36,7 @@ const mountActionbar = (showRunProgressBar: boolean) => {
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
@@ -50,17 +51,11 @@ const mountActionbar = (showRunProgressBar: boolean) => {
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})
return {
wrapper,
topMenuContainer
}
return { topMenuContainer }
}
describe('ComfyActionbar', () => {
@@ -70,31 +65,33 @@ describe('ComfyActionbar', () => {
})
it('teleports inline progress when run progress bar is enabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(true)
const { topMenuContainer } = renderActionbar(true)
try {
await nextTick()
/* eslint-disable testing-library/no-node-access -- Teleport target verification requires scoping to the container element */
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).not.toBeNull()
/* eslint-enable testing-library/no-node-access */
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
it('does not teleport inline progress when run progress bar is disabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(false)
const { topMenuContainer } = renderActionbar(false)
try {
await nextTick()
/* eslint-disable testing-library/no-node-access -- Teleport target verification requires scoping to the container element */
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).toBeNull()
/* eslint-enable testing-library/no-node-access */
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})

View File

@@ -32,47 +32,49 @@
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<Button
v-tooltip.bottom="cancelJobTooltipConfig"
variant="destructive"
size="icon"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
<BaseTooltip :text="t('menu.interrupt')" side="bottom">
<Button
variant="destructive"
size="icon"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
<BaseTooltip :text="queueHistoryTooltipText" side="bottom">
<Button
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
</BaseTooltip>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
@@ -108,7 +110,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
@@ -119,14 +121,9 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -368,16 +365,11 @@ watch(isDragging, (dragging) => {
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
const queueHistoryTooltipText = computed(() =>
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
const activeJobsLabel = computed(() => {
@@ -440,12 +432,7 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
: ['fixed shadow-interface', 'border-interface-stroke']
)
)
</script>

View File

@@ -13,7 +13,8 @@ import {
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import { render, screen } from '@/utils/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import ComfyQueueButton from './ComfyQueueButton.vue'
@@ -89,8 +90,9 @@ const stubs = {
function renderQueueButton() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const user = userEvent.setup()
return render(ComfyQueueButton, {
const result = render(ComfyQueueButton, {
global: {
plugins: [pinia, i18n],
directives: {
@@ -99,6 +101,8 @@ function renderQueueButton() {
stubs
}
})
return { ...result, user }
}
describe('ComfyQueueButton', () => {
@@ -148,7 +152,7 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
await user!.click(screen.getByTestId('queue-button'))
await user.click(screen.getByTestId('queue-button'))
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
@@ -167,7 +171,7 @@ describe('ComfyQueueButton', () => {
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await user!.click(screen.getByTestId('queue-button'))
await user.click(screen.getByTestId('queue-button'))
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-running')

View File

@@ -21,7 +21,7 @@
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
</div>
<Menu
v-if="isActive || isRoot"

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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
@@ -68,80 +69,75 @@ describe('BuilderFooterToolbar', () => {
mockState.settingView = false
})
function mountComponent() {
return mount(BuilderFooterToolbar, {
function renderComponent() {
const user = userEvent.setup()
render(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: { Button: false }
}
})
}
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
const buttons = wrapper.findAll('button')
return {
exit: buttons[0],
back: buttons[1],
next: buttons[2]
}
return { user }
}
it('disables back on the first step', () => {
mockState.mode = 'builder:inputs'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
renderComponent()
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled()
})
it('enables back on the second step', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeUndefined()
renderComponent()
expect(screen.getByRole('button', { name: /back/i })).toBeEnabled()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
})
it('disables next on arrange step when no outputs', () => {
mockState.mode = 'builder:arrange'
mockHasOutputs.value = false
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
})
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled()
})
it('calls setMode on back click', async () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /back/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /next/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
const { next } = getButtons(mountComponent())
await next.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /next/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockShowDialog).toHaveBeenCalledOnce()
})
it('calls exitBuilder on exit button click', async () => {
const { exit } = getButtons(mountComponent())
await exit.trigger('click')
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /exit app builder/i }))
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
})

View File

@@ -1,89 +1,103 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import BadgePill from './BadgePill.vue'
describe('BadgePill', () => {
it('renders text content', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Test Badge' }
})
expect(wrapper.text()).toBe('Test Badge')
expect(screen.getByText('Test Badge')).toBeInTheDocument()
})
it('renders icon when provided', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { icon: 'icon-[comfy--credits]', text: 'Credits' }
})
expect(wrapper.find('i.icon-\\[comfy--credits\\]').exists()).toBe(true)
expect(screen.getByTestId('badge-icon')).toHaveClass(
'icon-[comfy--credits]'
)
})
it('applies iconClass to icon', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: {
icon: 'icon-[comfy--credits]',
iconClass: 'text-amber-400'
}
})
const icon = wrapper.find('i')
expect(icon.classes()).toContain('text-amber-400')
expect(screen.getByTestId('badge-icon')).toHaveClass('text-amber-400')
})
it('uses default border color when no borderStyle', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Default' }
})
expect(wrapper.attributes('style')).toContain(
'border-color: var(--border-color)'
expect(screen.getByTestId('badge-pill')).toHaveAttribute(
'style',
expect.stringContaining('border-color: var(--border-color)')
)
})
it('applies solid border color when borderStyle is a color', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Colored', borderStyle: '#f59e0b' }
})
expect(wrapper.attributes('style')).toContain('border-color: #f59e0b')
expect(screen.getByTestId('badge-pill')).toHaveAttribute(
'style',
expect.stringContaining('border-color: #f59e0b')
)
})
it('applies gradient border when borderStyle contains linear-gradient', () => {
const gradient = 'linear-gradient(90deg, #3186FF, #FABC12)'
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Gradient', borderStyle: gradient }
})
const element = wrapper.element as HTMLElement
const element = screen.getByTestId('badge-pill') as HTMLElement
expect(element.style.borderColor).toBe('transparent')
expect(element.style.backgroundOrigin).toBe('border-box')
expect(element.style.backgroundClip).toBe('padding-box, border-box')
})
it('applies filled style with background and text color', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
const style = wrapper.attributes('style')
expect(style).toContain('border-color: #f59e0b')
expect(style).toContain('background-color: #f59e0b33')
expect(style).toContain('color: #f59e0b')
const pill = screen.getByTestId('badge-pill')
expect(pill).toHaveAttribute(
'style',
expect.stringContaining('border-color: #f59e0b')
)
expect(pill).toHaveAttribute(
'style',
expect.stringContaining('background-color: #f59e0b33')
)
expect(pill).toHaveAttribute(
'style',
expect.stringContaining('color: #f59e0b')
)
})
it('has foreground text when not filled', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Not Filled', borderStyle: '#f59e0b' }
})
expect(wrapper.classes()).toContain('text-foreground')
expect(screen.getByTestId('badge-pill')).toHaveClass('text-foreground')
})
it('does not have foreground text class when filled', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
expect(wrapper.classes()).not.toContain('text-foreground')
expect(screen.getByTestId('badge-pill')).not.toHaveClass('text-foreground')
})
it('renders slot content', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
slots: { default: 'Slot Content' }
})
expect(wrapper.text()).toBe('Slot Content')
expect(screen.getByText('Slot Content')).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,6 @@
<template>
<span
data-testid="badge-pill"
:class="
cn(
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
@@ -8,7 +9,11 @@
"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
<i
v-if="icon"
data-testid="badge-icon"
:class="cn(icon, 'size-2.5', iconClass)"
/>
<slot>{{ text }}</slot>
</span>
</template>

View File

@@ -1,8 +1,9 @@
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
@@ -14,13 +15,17 @@ describe('ColorCustomizationSelector', () => {
]
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
function renderComponent(
props: Record<string, unknown> = {},
callbacks: { 'onUpdate:modelValue'?: (value: string | null) => void } = {}
) {
const user = userEvent.setup()
const result = render(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
@@ -28,102 +33,123 @@ describe('ColorCustomizationSelector', () => {
props: {
modelValue: null,
colorOptions,
...props
...props,
...callbacks
}
})
return { ...result, user }
}
/** PrimeVue SelectButton renders toggle buttons with aria-pressed */
function getToggleButtons(container: Element) {
return container.querySelectorAll<HTMLButtonElement>( // eslint-disable-line testing-library/no-node-access -- PrimeVue SelectButton renders toggle buttons without standard ARIA radiogroup roles
'[data-pc-name="pctogglebutton"]'
)
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
const { container } = renderComponent()
expect(getToggleButtons(container)).toHaveLength(colorOptions.length + 1)
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
const { container } = renderComponent({ modelValue: '#0d6efd' })
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
const buttons = getToggleButtons(container)
expect(buttons[0]).toHaveAttribute('aria-pressed', 'true')
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
const { container } = renderComponent({ modelValue: '#123456' })
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
const buttons = getToggleButtons(container)
const customButton = buttons[buttons.length - 1]
expect(customButton).toHaveAttribute('aria-pressed', 'true')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker uses readonly input preview with no ARIA role
const colorPreview = container.querySelector(
'.p-colorpicker-preview'
) as HTMLInputElement | null
expect(colorPreview).not.toBeNull()
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const { container, user } = renderComponent()
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
const buttons = getToggleButtons(container)
await user.click(buttons[buttons.length - 1])
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker internal DOM
container.querySelector('[data-pc-name="colorpicker"]')
).not.toBeNull()
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const onUpdate = vi.fn()
const { container, user } = renderComponent(
{},
{ 'onUpdate:modelValue': onUpdate }
)
await selectButton.setValue(colorOptions[0])
const buttons = getToggleButtons(container)
await user.click(buttons[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const onUpdate = vi.fn()
const { container, user } = renderComponent(
{},
{ 'onUpdate:modelValue': onUpdate }
)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Custom is already selected by default (modelValue: null)
// Select Blue first, then switch to custom so onUpdate fires for Blue
const buttons = getToggleButtons(container)
await user.click(buttons[0]) // Select Blue
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
onUpdate.mockClear()
await user.click(buttons[buttons.length - 1]) // Switch to custom
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
// When switching to custom, the custom color value inherits from Blue ('0d6efd')
// and the watcher on customColorValue emits the update
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
const onUpdate = vi.fn()
const { container, user } = renderComponent(
{},
{ 'onUpdate:modelValue': onUpdate }
)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
const buttons = getToggleButtons(container)
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
// First select Blue
await user.click(buttons[0])
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
onUpdate.mockClear()
// Then switch to custom — inherits the Blue color
await user.click(buttons[buttons.length - 1])
// The customColorValue watcher fires with the inherited Blue value
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
const { container } = renderComponent({ modelValue: null })
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
const buttons = getToggleButtons(container)
const customButton = buttons[buttons.length - 1]
expect(customButton).toHaveAttribute('aria-pressed', 'true')
})
})

View File

@@ -1,140 +1,120 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
function renderComponent(
props: { modelValue: string; isEditing?: boolean },
callbacks: {
onEdit?: (...args: unknown[]) => void
onCancel?: (...args: unknown[]) => void
} = {}
) {
const user = userEvent.setup()
// @ts-expect-error fixme ts strict error
const mountComponent = (props, options = {}) => {
return mount(EditableText, {
render(EditableText, {
global: {
plugins: [PrimeVue],
components: { InputText }
},
props,
...options
props: {
...props,
...(callbacks.onEdit && { onEdit: callbacks.onEdit }),
...(callbacks.onCancel && { onCancel: callbacks.onCancel })
}
})
return { user }
}
it('renders span with modelValue when not editing', () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: false
})
expect(wrapper.find('span').text()).toBe('Test Text')
expect(wrapper.findComponent(InputText).exists()).toBe(false)
renderComponent({ modelValue: 'Test Text', isEditing: false })
expect(screen.getByText('Test Text')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('renders input with modelValue when editing', () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: true
})
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
'Test Text'
)
renderComponent({ modelValue: 'Test Text', isEditing: true })
expect(screen.queryByText('Test Text')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Test Text')
})
it('emits edit event when input is submitted', async () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: true
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keydown.enter')
// Blur event should have been triggered
expect(wrapper.findComponent(InputText).element).not.toBe(
document.activeElement
const onEdit = vi.fn()
const { user } = renderComponent(
{ modelValue: 'Test Text', isEditing: true },
{ onEdit }
)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'New Text')
await user.keyboard('{Enter}')
expect(onEdit).toHaveBeenCalledWith('New Text')
})
it('finishes editing on blur', async () => {
const wrapper = mountComponent({
modelValue: 'Test Text',
isEditing: true
})
await wrapper.findComponent(InputText).trigger('blur')
expect(wrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
const onEdit = vi.fn()
renderComponent({ modelValue: 'Test Text', isEditing: true }, { onEdit })
await fireEvent.blur(screen.getByRole('textbox'))
expect(onEdit).toHaveBeenCalledWith('Test Text')
})
it('cancels editing on escape key', async () => {
const wrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
// Change the input value
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape
await wrapper.findComponent(InputText).trigger('keydown.escape')
// Should emit cancel event
expect(wrapper.emitted('cancel')).toBeTruthy()
// Should NOT emit edit event
expect(wrapper.emitted('edit')).toBeFalsy()
// Input value should be reset to original
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
'Original Text'
const onEdit = vi.fn()
const onCancel = vi.fn()
const { user } = renderComponent(
{ modelValue: 'Original Text', isEditing: true },
{ onEdit, onCancel }
)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Modified Text')
await user.keyboard('{Escape}')
expect(onCancel).toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
expect(input).toHaveValue('Original Text')
})
it('does not save changes when escape is pressed and blur occurs', async () => {
const wrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
it('does not save changes when escape is pressed', async () => {
const onEdit = vi.fn()
const onCancel = vi.fn()
const { user } = renderComponent(
{ modelValue: 'Original Text', isEditing: true },
{ onEdit, onCancel }
)
// Change the input value
await wrapper.findComponent(InputText).setValue('Modified Text')
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'Modified Text')
// Escape triggers cancelEditing → blur internally, so no separate blur needed
await user.keyboard('{Escape}')
// Press escape (which triggers blur internally)
await wrapper.findComponent(InputText).trigger('keydown.escape')
// Manually trigger blur to simulate the blur that happens after escape
await wrapper.findComponent(InputText).trigger('blur')
// Should emit cancel but not edit
expect(wrapper.emitted('cancel')).toBeTruthy()
expect(wrapper.emitted('edit')).toBeFalsy()
expect(onCancel).toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
})
it('saves changes on enter but not on escape', async () => {
// Test Enter key saves changes
const enterWrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
await enterWrapper.findComponent(InputText).setValue('Saved Text')
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
// Trigger blur that happens after enter
await enterWrapper.findComponent(InputText).trigger('blur')
expect(enterWrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text'])
const onEditEnter = vi.fn()
const { user: userEnter } = renderComponent(
{ modelValue: 'Original Text', isEditing: true },
{ onEdit: onEditEnter }
)
// Test Escape key cancels changes with a fresh wrapper
const escapeWrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
expect(escapeWrapper.emitted('edit')).toBeFalsy()
const enterInput = screen.getByRole('textbox')
await userEnter.clear(enterInput)
await userEnter.type(enterInput, 'Saved Text')
await userEnter.keyboard('{Enter}')
expect(onEditEnter).toHaveBeenCalledWith('Saved Text')
})
})

View File

@@ -1,241 +1,210 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import RadioButton from 'primevue/radiobutton'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
describe('FormRadioGroup', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
function renderComponent(props: FormRadioGroupProps) {
return render(FormRadioGroup, {
global: { plugins: [PrimeVue] },
props
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
expect(radios[0]).toHaveAttribute('value', 'option1')
expect(radios[1]).toHaveAttribute('value', 'option2')
expect(radios[2]).toHaveAttribute('value', 'option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
expect(screen.getByText('option1')).toBeInTheDocument()
expect(screen.getByText('option2')).toBeInTheDocument()
expect(screen.getByText('option3')).toBeInTheDocument()
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'md',
options,
options: [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
] satisfies SettingOption[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
expect(radios[0]).toHaveAttribute('value', 'sm')
expect(radios[1]).toHaveAttribute('value', 'md')
expect(radios[2]).toHaveAttribute('value', 'lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
expect(screen.getByText('Small')).toBeInTheDocument()
expect(screen.getByText('Medium')).toBeInTheDocument()
expect(screen.getByText('Large')).toBeInTheDocument()
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'Option A',
options,
options: [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
] satisfies SettingOption[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
const radios = screen.getAllByRole('radio')
expect(radios[0]).toHaveAttribute('value', 'Option A')
expect(radios[1]).toHaveAttribute('value', 'Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 2,
options,
options: [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
],
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
expect(radios[0]).toHaveAttribute('value', '1')
expect(radios[1]).toHaveAttribute('value', '2')
expect(radios[2]).toHaveAttribute('value', '3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
expect(screen.getByText('First Option')).toBeInTheDocument()
expect(screen.getByText('Second Option')).toBeInTheDocument()
expect(screen.getByText('Third Option')).toBeInTheDocument()
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'complex',
options,
options: [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
] as (string | SettingOption)[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
expect(radios[0]).toHaveAttribute('value', 'Simple String')
expect(radios[1]).toHaveAttribute('value', 'complex')
expect(radios[2]).toHaveAttribute('value', 'Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
expect(screen.getByText('Simple String')).toBeInTheDocument()
expect(screen.getByText('Complex Option')).toBeInTheDocument()
expect(screen.getByText('Another String')).toBeInTheDocument()
})
it('handles empty options array', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
expect(screen.queryAllByRole('radio')).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
expect(screen.queryAllByRole('radio')).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
renderComponent({
modelValue: 'opt1',
options,
options: [{ label: 'Option 1', val: 'opt1' }],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
expect(screen.getAllByRole('radio')).toHaveLength(1)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
it('sets correct id and name attributes on inputs', () => {
renderComponent({
modelValue: 'A',
options,
options: ['A', 'B'],
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
const radios = screen.getAllByRole('radio')
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
expect(radios[0]).toHaveAttribute('id', 'my-radio-group-A')
expect(radios[0]).toHaveAttribute('name', 'my-radio-group')
expect(radios[1]).toHaveAttribute('id', 'my-radio-group-B')
expect(radios[1]).toHaveAttribute('name', 'my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
renderComponent({
modelValue: 'Yes',
options,
options: ['Yes', 'No'],
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
expect(screen.getByText('Yes')).toHaveAttribute(
'for',
'confirm-radio-Yes'
)
expect(screen.getByText('No')).toHaveAttribute('for', 'confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'opt1',
options,
options: [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
] satisfies SettingOption[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
const radios = screen.getAllByRole('radio')
// PrimeVue RadioButton places aria-describedby on its root <div>, not the <input>
// eslint-disable-next-line testing-library/no-node-access
expect(radios[0].closest('[aria-describedby]')).toHaveAttribute(
'aria-describedby',
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
// eslint-disable-next-line testing-library/no-node-access
expect(radios[1].closest('[aria-describedby]')).toHaveAttribute(
'aria-describedby',
'Option 2-label'
)
})

View File

@@ -1,6 +1,6 @@
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ImageLightbox from './ImageLightbox.vue'
@@ -13,49 +13,39 @@ const i18n = createI18n({
fallbackWarn: false
})
function findCloseButton() {
const el = document.body.querySelector('[aria-label="g.close"]')
return el ? new DOMWrapper(el) : null
}
describe(ImageLightbox, () => {
let wrapper: VueWrapper
afterEach(() => {
wrapper.unmount()
})
function mountComponent(props: { src: string; alt?: string }, open = true) {
wrapper = mount(ImageLightbox, {
function renderComponent(props: { src: string; alt?: string }, open = true) {
const user = userEvent.setup()
const onUpdate = vi.fn()
const result = render(ImageLightbox, {
global: { plugins: [i18n] },
props: { ...props, modelValue: open },
attachTo: document.body
props: {
...props,
modelValue: open,
'onUpdate:modelValue': onUpdate
}
})
return wrapper
return { ...result, user, onUpdate }
}
it('renders the image with correct src and alt when open', async () => {
mountComponent({ src: '/test.png', alt: 'Test image' })
await flushPromises()
const img = document.body.querySelector('img')
expect(img).toBeTruthy()
expect(img?.getAttribute('src')).toBe('/test.png')
expect(img?.getAttribute('alt')).toBe('Test image')
renderComponent({ src: '/test.png', alt: 'Test image' })
const img = await screen.findByRole('img')
expect(img).toHaveAttribute('src', '/test.png')
expect(img).toHaveAttribute('alt', 'Test image')
})
it('does not render dialog content when closed', async () => {
mountComponent({ src: '/test.png' }, false)
await flushPromises()
expect(document.body.querySelector('img')).toBeNull()
it('does not render dialog content when closed', () => {
renderComponent({ src: '/test.png' }, false)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('emits update:modelValue false when close button is clicked', async () => {
mountComponent({ src: '/test.png' })
await flushPromises()
const closeButton = findCloseButton()
expect(closeButton).toBeTruthy()
await closeButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
const { user, onUpdate } = renderComponent({ src: '/test.png' })
const closeButton = await screen.findByLabelText('g.close')
await user.click(closeButton)
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith(false)
})
})
})

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import Badge from 'primevue/badge'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
@@ -12,7 +12,6 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
// Create a mock i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -33,7 +32,6 @@ describe('TreeExplorerTreeNode', () => {
const mockHandleEditLabel = vi.fn()
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
app.use(PrimeVue)
vi.useFakeTimers()
@@ -44,7 +42,7 @@ describe('TreeExplorerTreeNode', () => {
})
it('renders correctly', () => {
const wrapper = mount(TreeExplorerTreeNode, {
render(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
@@ -55,18 +53,16 @@ describe('TreeExplorerTreeNode', () => {
}
})
expect(wrapper.find('.tree-node').exists()).toBe(true)
expect(wrapper.find('.tree-folder').exists()).toBe(true)
expect(wrapper.find('.tree-leaf').exists()).toBe(false)
expect(wrapper.findComponent(EditableText).props('modelValue')).toBe(
'Test Node'
)
// @ts-expect-error fixme ts strict error
expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3')
const treeNode = screen.getByTestId('tree-node-1')
expect(treeNode).toBeInTheDocument()
expect(treeNode).toHaveClass('tree-folder')
expect(treeNode).not.toHaveClass('tree-leaf')
expect(screen.getByText('Test Node')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
it('makes node label editable when isEditingLabel is true', () => {
render(TreeExplorerTreeNode, {
props: {
node: {
...mockNode,
@@ -82,14 +78,13 @@ describe('TreeExplorerTreeNode', () => {
}
})
const editableText = wrapper.findComponent(EditableText)
expect(editableText.props('isEditing')).toBe(true)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('triggers handleEditLabel callback when editing is finished', async () => {
const handleEditLabelMock = vi.fn()
const wrapper = mount(TreeExplorerTreeNode, {
render(TreeExplorerTreeNode, {
props: {
node: {
...mockNode,
@@ -103,8 +98,9 @@ describe('TreeExplorerTreeNode', () => {
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Trigger blur on the input to finish editing (fires the 'edit' event)
await fireEvent.blur(screen.getByRole('textbox'))
expect(handleEditLabelMock).toHaveBeenCalledOnce()
})
})

View File

@@ -1,64 +1,66 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (
function renderComponent(
props: ComponentProps<typeof UrlInput> & {
placeholder?: string
disabled?: boolean
},
options = {}
) => {
return mount(UrlInput, {
'onUpdate:modelValue'?: (value: string) => void
}
) {
const user = userEvent.setup()
const result = render(UrlInput, {
global: {
plugins: [PrimeVue],
components: { IconField, InputIcon, InputText }
},
props,
...options
props
})
return { ...result, user }
}
it('passes through additional attributes to input element', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: '',
placeholder: 'Enter URL',
disabled: true
})
expect(wrapper.find('input').attributes('disabled')).toBe('')
expect(screen.getByRole('textbox')).toBeDisabled()
})
it('emits update:modelValue on blur', async () => {
const wrapper = mountComponent({
const onUpdate = vi.fn()
const { user } = renderComponent({
modelValue: '',
placeholder: 'Enter URL'
placeholder: 'Enter URL',
'onUpdate:modelValue': onUpdate
})
const input = wrapper.find('input')
await input.setValue('https://test.com/')
await input.trigger('blur')
const input = screen.getByRole('textbox')
await user.type(input, 'https://test.com/')
expect(onUpdate).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([
'https://test.com/'
])
await user.tab()
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith('https://test.com/')
})
it('renders spinner when validation is loading', async () => {
const wrapper = mountComponent({
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () =>
@@ -67,43 +69,46 @@ describe('UrlInput', () => {
})
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-spinner').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-spinner class with no ARIA role
expect(container.querySelector('.pi-spinner')).not.toBeNull()
})
it('renders check icon when validation is valid', async () => {
const wrapper = mountComponent({
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(true)
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
expect(container.querySelector('.pi-check')).not.toBeNull()
})
it('renders cross icon when validation is invalid', async () => {
const wrapper = mountComponent({
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(false)
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-times').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-times class with no ARIA role
expect(container.querySelector('.pi-times')).not.toBeNull()
})
it('validates on mount', async () => {
const wrapper = mountComponent({
const { container } = renderComponent({
modelValue: 'https://test.com',
validateUrlFn: () => Promise.resolve(true)
})
@@ -111,12 +116,13 @@ describe('UrlInput', () => {
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
expect(container.querySelector('.pi-check')).not.toBeNull()
})
it('triggers validation when clicking the validation icon', async () => {
let validationCount = 0
const wrapper = mountComponent({
const { container, user } = renderComponent({
modelValue: 'https://test.com',
validateUrlFn: () => {
validationCount++
@@ -129,7 +135,9 @@ describe('UrlInput', () => {
await nextTick()
// Click the validation icon
await wrapper.find('.pi-check').trigger('click')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
const icon = container.querySelector('.pi-check')!
await user.click(icon)
await nextTick()
await nextTick()
@@ -138,7 +146,7 @@ describe('UrlInput', () => {
it('prevents multiple simultaneous validations', async () => {
let validationCount = 0
const wrapper = mountComponent({
const { container, rerender, user } = renderComponent({
modelValue: '',
validateUrlFn: () => {
validationCount++
@@ -148,14 +156,16 @@ describe('UrlInput', () => {
}
})
await wrapper.setProps({ modelValue: 'https://test.com' })
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// Trigger multiple validations in quick succession
await wrapper.find('.pi-spinner').trigger('click')
await wrapper.find('.pi-spinner').trigger('click')
await wrapper.find('.pi-spinner').trigger('click')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon
const spinner = container.querySelector('.pi-spinner')!
await user.click(spinner)
await user.click(spinner)
await user.click(spinner)
await nextTick()
await nextTick()
@@ -165,55 +175,49 @@ describe('UrlInput', () => {
describe('input cleaning functionality', () => {
it('trims whitespace when user types', async () => {
const wrapper = mountComponent({
renderComponent({
modelValue: '',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
const input = screen.getByRole('textbox')
// Test leading whitespace
await input.setValue(' https://leading-space.com')
await input.trigger('input')
// The component strips whitespace on input via handleInput
// We use fireEvent.input to simulate the input event handler directly
await fireEvent.update(input, ' https://leading-space.com')
await nextTick()
expect(input.element.value).toBe('https://leading-space.com')
expect(input).toHaveValue('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await fireEvent.update(input, 'https://trailing-space.com ')
await nextTick()
expect(input.element.value).toBe('https://trailing-space.com')
expect(input).toHaveValue('https://trailing-space.com')
// Test both leading and trailing whitespace
await input.setValue(' https://both-spaces.com ')
await input.trigger('input')
await fireEvent.update(input, ' https://both-spaces.com ')
await nextTick()
expect(input.element.value).toBe('https://both-spaces.com')
expect(input).toHaveValue('https://both-spaces.com')
// Test whitespace in the middle of the URL
await input.setValue('https:// middle-space.com')
await input.trigger('input')
await fireEvent.update(input, 'https:// middle-space.com')
await nextTick()
expect(input.element.value).toBe('https://middle-space.com')
expect(input).toHaveValue('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
const wrapper = mountComponent({
const { rerender } = renderComponent({
modelValue: ' https://initial-value.com ',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
const input = screen.getByRole('textbox')
// Check initial value is trimmed
expect(input.element.value).toBe('https://initial-value.com')
expect(input).toHaveValue('https://initial-value.com')
// Update props with whitespace
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
await rerender({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
expect(input.element.value).toBe('https://updated-value.com')
expect(input).toHaveValue('https://updated-value.com')
})
})
})

View File

@@ -1,10 +1,9 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import { fireEvent, render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import UserAvatar from './UserAvatar.vue'
@@ -24,85 +23,73 @@ const i18n = createI18n({
})
describe('UserAvatar', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
return mount(UserAvatar, {
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
return render(UserAvatar, {
global: {
plugins: [PrimeVue, i18n],
components: { Avatar }
plugins: [PrimeVue, i18n]
},
props
})
}
it('renders correctly with photo Url', async () => {
const wrapper = mountComponent({
it('renders correctly with photo Url', () => {
renderComponent({
photoUrl: 'https://example.com/avatar.jpg'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBe('https://example.com/avatar.jpg')
expect(avatar.props('icon')).toBeNull()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/avatar.jpg'
)
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
})
it('renders with default icon when no photo Url is provided', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: undefined
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('renders with default icon when provided photo Url is null', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: null
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('falls back to icon when image fails to load', async () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: 'https://example.com/broken-image.jpg'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.props('icon')).toBeNull()
const img = screen.getByRole('img')
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
// Simulate image load error
avatar.vm.$emit('error')
await fireEvent.error(img)
await nextTick()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('uses provided ariaLabel', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: 'https://example.com/avatar.jpg',
ariaLabel: 'Custom Label'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.attributes('aria-label')).toBe('Custom Label')
expect(screen.getByLabelText('Custom Label')).toBeInTheDocument()
})
it('falls back to i18n translation when no ariaLabel is provided', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: 'https://example.com/avatar.jpg'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.attributes('aria-label')).toBe('User Avatar')
expect(screen.getByLabelText('User Avatar')).toBeInTheDocument()
})
})

View File

@@ -3,7 +3,12 @@
class="aspect-square bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"
:pt="{
icon: {
class: { 'size-4': !hasAvatar },
'data-testid': 'avatar-icon'
}
}"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"

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 { createI18n } from 'vue-i18n'
@@ -32,8 +32,8 @@ const mockBalance = vi.hoisted(() => ({
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
balance: mockBalance.value,
isFetchingBalance: mockIsFetchingBalance.value
}))
@@ -50,19 +50,19 @@ describe('UserCredit', () => {
mockIsFetchingBalance.value = false
})
const mountComponent = (props = {}) => {
const renderComponent = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(UserCredit, {
return render(UserCredit, {
props,
global: {
plugins: [i18n],
stubs: {
Skeleton: true,
Skeleton: { template: '<div data-testid="skeleton" />' },
Tag: true
}
}
@@ -77,8 +77,8 @@ describe('UserCredit', () => {
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Credits')
renderComponent()
expect(screen.getByText(/Credits/)).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
@@ -88,8 +88,8 @@ describe('UserCredit', () => {
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('0')
renderComponent()
expect(screen.getByText(/\b0\b/)).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
@@ -99,8 +99,8 @@ describe('UserCredit', () => {
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('-')
renderComponent()
expect(screen.getByText((text) => text.includes('-'))).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
@@ -109,8 +109,8 @@ describe('UserCredit', () => {
currency: 'usd'
} as typeof mockBalance.value
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Credits')
renderComponent()
expect(screen.getByText(/Credits/)).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
@@ -118,8 +118,8 @@ describe('UserCredit', () => {
currency: 'usd'
} as typeof mockBalance.value
const wrapper = mountComponent()
expect(wrapper.text()).toContain('0')
renderComponent()
expect(screen.getByText(/\b0\b/)).toBeInTheDocument()
})
})
@@ -127,8 +127,8 @@ describe('UserCredit', () => {
it('shows skeleton when loading', () => {
mockIsFetchingBalance.value = true
const wrapper = mountComponent()
expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true)
renderComponent()
expect(screen.getAllByTestId('skeleton').length).toBeGreaterThan(0)
})
})
})

View File

@@ -30,14 +30,14 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -22,8 +22,8 @@ describe('ConfirmationDialogContent', () => {
setActivePinia(createPinia())
})
function mountComponent(props: Partial<Props> = {}) {
return mount(ConfirmationDialogContent, {
function renderComponent(props: Partial<Props> = {}) {
return render(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
@@ -39,7 +39,7 @@ describe('ConfirmationDialogContent', () => {
it('renders long messages without breaking layout', () => {
const longFilename =
'workflow_checkpoint_' + 'a'.repeat(200) + '.safetensors'
const wrapper = mountComponent({ message: longFilename })
expect(wrapper.text()).toContain(longFilename)
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
})

View File

@@ -147,7 +147,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
@@ -167,7 +167,7 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)

View File

@@ -156,7 +156,7 @@ import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -171,7 +171,7 @@ const { isInsufficientCredits = false } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()

View File

@@ -21,10 +21,10 @@ import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const loading = ref(false)
const { onSuccess } = defineProps<{

View File

@@ -116,12 +116,12 @@ import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
@@ -133,8 +133,8 @@ interface CreditHistoryItemData {
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()

View File

@@ -18,8 +18,8 @@ import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
loading: mockLoading()
}))
}))

View File

@@ -100,9 +100,9 @@ import {
} from '@/platform/remoteConfig/remoteConfig'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const comfyPlatformBaseUrl = computed(() =>

View File

@@ -35,15 +35,15 @@ vi.mock('firebase/auth', () => ({
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}

View File

@@ -88,14 +88,14 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const authStore = useAuthStore()
const authActions = useAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
@@ -127,6 +127,6 @@ const handleForgotPassword = async (
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)
await authActions.sendPasswordReset(email)
}
</script>

View File

@@ -54,12 +54,12 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import PasswordFields from './PasswordFields.vue'
const { t } = useI18n()
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const emit = defineEmits<{

View File

@@ -9,7 +9,7 @@
role="alert"
aria-live="assertive"
data-testid="error-overlay"
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
@@ -27,10 +27,10 @@
</div>
<!-- Body -->
<div class="px-4 pb-3">
<div class="px-4 pb-3" data-testid="error-overlay-messages">
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="(message, idx) in groupedErrorMessages"
v-for="(message, idx) in overlayMessages"
:key="idx"
class="flex min-w-0 items-baseline gap-2 text-sm/snug text-muted-foreground"
>
@@ -46,7 +46,12 @@
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-3">
<Button variant="muted-textonly" size="unset" @click="dismiss">
<Button
variant="muted-textonly"
size="unset"
data-testid="error-overlay-dismiss"
@click="dismiss"
>
{{ t('g.dismiss') }}
</Button>
<Button
@@ -55,9 +60,7 @@
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
{{ appMode ? t('linearMode.error.goto') : seeErrorsLabel }}
</Button>
</div>
</div>
@@ -84,7 +87,59 @@ const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const { allErrorGroups, missingModelGroups } = useErrorGroups(ref(''), t)
const singleErrorType = computed(() => {
const types = new Set(allErrorGroups.value.map((g) => g.type))
return types.size === 1 ? [...types][0] : null
})
const friendlyMessageMap: Record<string, () => string> = {
missing_node: () => t('errorOverlay.missingNodes'),
swap_nodes: () => t('errorOverlay.swapNodes'),
missing_media: () => t('errorOverlay.missingMedia'),
missing_model: () => {
const modelCount = missingModelGroups.value.reduce(
(count, g) => count + g.models.length,
0
)
return t('errorOverlay.missingModels', { count: modelCount }, modelCount)
}
}
function toFriendlyMessage(group: (typeof allErrorGroups.value)[number]) {
return friendlyMessageMap[group.type]?.() ?? null
}
const overlayMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
const friendly = toFriendlyMessage(group)
if (friendly) {
messages.add(friendly)
} else if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
}
}
return Array.from(messages)
})
const seeErrorsLabel = computed(() => {
const labelMap: Record<string, string> = {
missing_node: t('errorOverlay.showMissingNodes'),
missing_model: t('errorOverlay.showMissingModels'),
swap_nodes: t('errorOverlay.showSwapNodes'),
missing_media: t('errorOverlay.showMissingMedia')
}
if (singleErrorType.value) {
return labelMap[singleErrorType.value] ?? t('errorOverlay.seeErrors')
}
return t('errorOverlay.seeErrors')
})
const errorCountLabel = computed(() =>
t(

View File

@@ -11,6 +11,7 @@
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-in-action"
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
@@ -23,6 +24,7 @@
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-out-action"
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
@@ -35,6 +37,7 @@
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
data-testid="zoom-to-fit-action"
@click="executeCommand('Comfy.Canvas.FitView')"
>
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
@@ -46,6 +49,7 @@
<div
ref="zoomInputContainer"
class="zoomInputContainer flex items-center gap-1 rounded-sm bg-input-surface p-2"
data-testid="zoom-percentage-input"
>
<InputNumber
:default-value="canvasStore.appScalePercentage"

View File

@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
@@ -29,9 +31,9 @@ describe('BypassButton', () => {
locale: 'en',
messages: {
en: {
selectionToolbox: {
bypassButton: {
tooltip: 'Toggle bypass mode'
commands: {
Comfy_Canvas_ToggleSelectedNodes_Bypass: {
label: 'Toggle bypass mode'
}
}
}
@@ -46,8 +48,10 @@ describe('BypassButton', () => {
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(BypassButton, {
function renderComponent() {
const user = userEvent.setup()
render(BypassButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
@@ -56,28 +60,28 @@ describe('BypassButton', () => {
}
}
})
return { user }
}
it('should render bypass button', () => {
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
it('should have correct test id', () => {
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
it('should execute bypass command when clicked', async () => {
canvasStore.selectedItems = [getMockLGraphNode()]
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
await wrapper.find('button').trigger('click')
const { user } = renderComponent()
await user.click(screen.getByTestId('bypass-button'))
expect(executeSpy).toHaveBeenCalledWith(
'Comfy.Canvas.ToggleSelectedNodes.Bypass'
@@ -90,21 +94,18 @@ describe('BypassButton', () => {
})
canvasStore.selectedItems = [bypassedNode]
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
const { user } = renderComponent()
// Click to trigger the reactivity update
await wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()
await user.click(screen.getByTestId('bypass-button'))
await nextTick()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('bypass-button')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
@@ -21,26 +21,59 @@ const i18n = createI18n({
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
const options = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' }
]
function mountInParent(
multiSelectProps: Record<string, unknown> = {},
modelValue: { name: string; value: string }[] = []
) {
const parentEscapeCount = { value: 0 }
const Parent = {
template:
'<div @keydown.escape="onEsc"><MultiSelect v-model="sel" :options="options" v-bind="extraProps" /></div>',
components: { MultiSelect },
setup() {
return {
sel: ref(modelValue),
options,
extraProps: multiSelectProps,
onEsc: () => {
parentEscapeCount.value++
}
}
})
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
}
function dispatchEscape(element: Element) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true
})
)
}
function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
describe('MultiSelect', () => {
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const { wrapper } = mountInParent()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
@@ -57,4 +90,65 @@ describe('MultiSelect', () => {
wrapper.unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
await nextTick()
const content = findContentElement()
expect(content).not.toBeNull()
dispatchEscape(content!)
await nextTick()
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('data-state')).toBe('open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
wrapper.unmount()
})
})
describe('selected count badge', () => {
it('shows selected count when items are selected', () => {
const { wrapper } = mountInParent({}, [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' }
])
expect(wrapper.text()).toContain('2')
wrapper.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()))
expect(countBadge).toBeUndefined()
wrapper.unmount()
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<ComboboxRoot
v-model="selectedItems"
v-model:open="isOpen"
multiple
by="value"
:disabled
@@ -13,17 +14,10 @@
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
selectTriggerVariants({
size,
border: selectedCount > 0 ? 'active' : 'none'
})
)
"
>
@@ -45,9 +39,7 @@
{{ selectedCount }}
</span>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<div :class="selectDropdownClass">
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
@@ -59,19 +51,8 @@
:side-offset="8"
align="start"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
>
<div
@@ -132,13 +113,7 @@
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
:class="cn('group', selectItemVariants({ layout: 'multi' }))"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
@@ -151,7 +126,7 @@
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
<ComboboxEmpty :class="selectEmptyMessageClass">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
@@ -176,13 +151,21 @@ import {
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
selectEmptyMessageClass,
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from './select.variants'
import type { SelectOption } from './types'
defineOptions({
@@ -232,8 +215,16 @@ const selectedItems = defineModel<SelectOption[]>({
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const isOpen = ref(false)
const selectedCount = computed(() => selectedItems.value.length)
function onContentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
stopEscapeToDocument(event)
isOpen.value = false
}
}
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}

View File

@@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SingleSelect from './SingleSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
singleSelectDropdown: 'Single-select dropdown'
}
}
}
})
const options = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' }
]
function dispatchEscape(element: Element) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true
})
)
}
function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
function mountInParent(modelValue?: string) {
const parentEscapeCount = { value: 0 }
const Parent = {
template:
'<div @keydown.escape="onEsc"><SingleSelect v-model="sel" :options="options" label="Pick" /></div>',
components: { SingleSelect },
setup() {
return {
sel: ref(modelValue),
options,
onEsc: () => {
parentEscapeCount.value++
}
}
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
}
async function openSelect(triggerEl: HTMLElement) {
if (!triggerEl.hasPointerCapture) {
triggerEl.hasPointerCapture = () => false
triggerEl.releasePointerCapture = () => {}
}
triggerEl.dispatchEvent(
new PointerEvent('pointerdown', {
button: 0,
pointerType: 'mouse',
bubbles: true
})
)
await nextTick()
}
describe('SingleSelect', () => {
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
const content = findContentElement()
expect(content).not.toBeNull()
dispatchEscape(content!)
await nextTick()
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
expect(trigger.attributes('data-state')).toBe('open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
wrapper.unmount()
})
})
})

View File

@@ -1,23 +1,15 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectRoot v-model="selectedItem" v-model:open="isOpen" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
'bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
)
selectTriggerVariants({
size,
border: invalid ? 'invalid' : 'none'
})
"
>
<div
@@ -35,9 +27,7 @@
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<div :class="selectDropdownClass">
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
@@ -48,20 +38,8 @@
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
@keydown="onContentKeydown"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
@@ -71,16 +49,7 @@
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
:class="selectItemVariants({ layout: 'single' })"
>
<SelectItemText class="truncate">
{{ opt.name }}
@@ -112,11 +81,19 @@ import {
SelectValue,
SelectViewport
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from './select.variants'
import type { SelectOption } from './types'
defineOptions({
@@ -155,6 +132,14 @@ const {
const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const isOpen = ref(false)
function onContentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
stopEscapeToDocument(event)
isOpen.value = false
}
}
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,

View File

@@ -0,0 +1,50 @@
import { cva } from 'cva'
export const selectTriggerVariants = cva({
base: 'relative inline-flex cursor-pointer items-center select-none rounded-lg bg-secondary-background text-base-foreground outline-none transition-all duration-200 ease-in-out hover:bg-secondary-background-hover border-[2.5px] border-solid disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background',
variants: {
size: {
md: 'h-8',
lg: 'h-10'
},
border: {
none: 'border-transparent focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
active: 'border-base-foreground',
invalid: 'border-destructive-background'
}
},
defaultVariants: {
size: 'lg',
border: 'none'
}
})
export const selectItemVariants = cva({
base: 'flex cursor-pointer items-center px-2 outline-none hover:bg-secondary-background-hover',
variants: {
layout: {
multi:
'h-10 shrink-0 gap-2 rounded-lg data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected',
single:
'relative w-full justify-between gap-3 rounded-sm py-3 text-sm select-none focus:bg-secondary-background-hover data-[state=checked]:bg-secondary-background-selected data-[state=checked]:hover:bg-secondary-background-selected'
}
},
defaultVariants: {
layout: 'multi'
}
})
export const selectContentClass =
'z-3000 overflow-hidden rounded-lg p-2 bg-base-background text-base-foreground border border-solid border-border-default shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2'
export const selectDropdownClass =
'flex shrink-0 cursor-pointer items-center justify-center px-3'
export const selectEmptyMessageClass = 'px-3 pb-4 text-sm text-muted-foreground'
export function stopEscapeToDocument(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation()
event.stopImmediatePropagation()
}
}

View File

@@ -32,7 +32,7 @@
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
class="m-0 text-2xs/normal font-normal text-muted-foreground"
>
{{ nodeDef.description }}
</p>

View File

@@ -52,11 +52,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const BaseTooltipStub = {
template: '<slot />'
}
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})

View File

@@ -1,91 +1,94 @@
<template>
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-56 flex-col items-stretch font-inter">
<BaseTooltip :text="t('g.more')" side="top">
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleDockedJobHistory(close)"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
</template>
<template #default="{ close }">
<div class="flex min-w-56 flex-col items-stretch font-inter">
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onClearHistoryFromMenu(close)"
@click="onToggleDockedJobHistory(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start text-left leading-tight wrap-break-word"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs font-light text-text-secondary">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
/>
</Button>
</template>
</div>
</template>
</Popover>
</div>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--hourglass] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="md"
@click="onClearHistoryFromMenu(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start text-left leading-tight wrap-break-word"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs font-light text-text-secondary">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
</span>
</Button>
</template>
</div>
</template>
</Popover>
</div>
</BaseTooltip>
</template>
<script setup lang="ts">
@@ -95,7 +98,7 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -108,7 +111,6 @@ const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const showClearHistoryAction = computed(() => !isCloud)

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 { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
@@ -17,8 +17,9 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
function renderComponent(props: { hidden?: boolean } = {}) {
return render(QueueInlineProgress, { props })
}
describe('QueueInlineProgress', () => {
beforeEach(() => {
@@ -29,47 +30,53 @@ describe('QueueInlineProgress', () => {
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
renderComponent({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
expect(
screen.queryByTestId('queue-inline-progress')
).not.toBeInTheDocument()
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
expect(
screen.queryByTestId('queue-inline-progress')
).not.toBeInTheDocument()
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
expect(
screen.queryByTestId('queue-inline-progress')
).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
v-if="shouldShow"
data-testid="queue-inline-progress"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)

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 { createI18n } from 'vue-i18n'
@@ -29,71 +29,77 @@ const i18n = createI18n({
}
})
const mountComponent = (notification: QueueNotificationBannerItem) =>
mount(QueueNotificationBanner, {
function renderComponent(notification: QueueNotificationBannerItem) {
return render(QueueNotificationBanner, {
props: { notification },
global: {
plugins: [i18n]
}
})
}
describe(QueueNotificationBanner, () => {
it('renders singular queued message without count prefix', () => {
const wrapper = mountComponent({
renderComponent({
type: 'queued',
count: 1
})
expect(wrapper.text()).toContain('Job added to queue')
expect(wrapper.text()).not.toContain('1 job')
expect(screen.getByText('Job added to queue')).toBeInTheDocument()
expect(screen.queryByText(/1 job/)).not.toBeInTheDocument()
})
it('renders queued message with pluralization', () => {
const wrapper = mountComponent({
renderComponent({
type: 'queued',
count: 2
})
expect(wrapper.text()).toContain('2 jobs added to queue')
expect(wrapper.html()).toContain('icon-[lucide--check]')
expect(screen.getByText('2 jobs added to queue')).toBeInTheDocument()
expect(screen.getByTestId('notification-icon')).toHaveClass(
'icon-[lucide--check]'
)
})
it('renders queued pending message with spinner icon', () => {
const wrapper = mountComponent({
renderComponent({
type: 'queuedPending',
count: 1
})
expect(wrapper.text()).toContain('Job queueing')
expect(wrapper.html()).toContain('icon-[lucide--loader-circle]')
expect(wrapper.html()).toContain('animate-spin')
expect(screen.getByText('Job queueing')).toBeInTheDocument()
const icon = screen.getByTestId('notification-icon')
expect(icon).toHaveClass('icon-[lucide--loader-circle]')
expect(icon).toHaveClass('animate-spin')
})
it('renders failed message and alert icon', () => {
const wrapper = mountComponent({
renderComponent({
type: 'failed',
count: 1
})
expect(wrapper.text()).toContain('Job failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
expect(screen.getByText('Job failed')).toBeInTheDocument()
expect(screen.getByTestId('notification-icon')).toHaveClass(
'icon-[lucide--circle-alert]'
)
})
it('renders completed message with thumbnail preview when provided', () => {
const wrapper = mountComponent({
renderComponent({
type: 'completed',
count: 3,
thumbnailUrls: ['https://example.com/preview.png']
})
expect(wrapper.text()).toContain('3 jobs completed')
const image = wrapper.get('img')
expect(image.attributes('src')).toBe('https://example.com/preview.png')
expect(image.attributes('alt')).toBe('Preview')
expect(screen.getByText('3 jobs completed')).toBeInTheDocument()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/preview.png')
expect(image).toHaveAttribute('alt', 'Preview')
})
it('renders two completion thumbnail previews', () => {
const wrapper = mountComponent({
renderComponent({
type: 'completed',
count: 4,
thumbnailUrls: [
@@ -102,18 +108,20 @@ describe(QueueNotificationBanner, () => {
]
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe(
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0]).toHaveAttribute(
'src',
'https://example.com/preview-1.png'
)
expect(images[1].attributes('src')).toBe(
expect(images[1]).toHaveAttribute(
'src',
'https://example.com/preview-2.png'
)
})
it('caps completion thumbnail previews at two', () => {
const wrapper = mountComponent({
renderComponent({
type: 'completed',
count: 4,
thumbnailUrls: [
@@ -124,12 +132,14 @@ describe(QueueNotificationBanner, () => {
]
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe(
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0]).toHaveAttribute(
'src',
'https://example.com/preview-1.png'
)
expect(images[1].attributes('src')).toBe(
expect(images[1]).toHaveAttribute(
'src',
'https://example.com/preview-2.png'
)
})

View File

@@ -41,6 +41,7 @@
v-else
:class="cn(iconClass, 'size-4', iconColorClass)"
aria-hidden="true"
data-testid="notification-icon"
/>
</div>
<div class="flex h-full items-center">

View File

@@ -1,9 +1,8 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
@@ -27,11 +26,6 @@ const i18n = createI18n({
}
})
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
@@ -43,6 +37,10 @@ const COPY = {
viewAllJobs: 'View all jobs'
}
const BaseTooltipStub = {
template: '<slot />'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
@@ -58,8 +56,8 @@ const mountComponent = (props: Record<string, unknown> = {}) =>
},
global: {
plugins: [i18n],
directives: {
tooltip: tooltipDirectiveStub
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})
@@ -113,13 +111,4 @@ describe('QueueOverlayActive', () => {
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')
})
})

View File

@@ -42,18 +42,22 @@
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<Button
<BaseTooltip
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
:text="t('sideToolbar.queueProgressOverlay.cancelJobTooltip')"
side="top"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</Button>
</BaseTooltip>
</div>
<div class="flex items-center gap-2">
@@ -63,18 +67,22 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<Button
<BaseTooltip
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
:text="t('sideToolbar.queueProgressOverlay.clearQueueTooltip')"
side="top"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</Button>
</BaseTooltip>
</div>
</div>
@@ -91,11 +99,10 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
defineProps<{
totalProgressStyle: Record<string, string>
@@ -115,10 +122,4 @@ defineEmits<{
}>()
const { t } = useI18n()
const cancelJobTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
</script>

View File

@@ -48,11 +48,9 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
const BaseTooltipStub = {
template: '<slot />'
}
const mountHeader = (props = {}) =>
@@ -64,7 +62,9 @@ const mountHeader = (props = {}) =>
},
global: {
plugins: [i18n],
directives: { tooltip: tooltipDirectiveStub }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})
@@ -105,14 +105,11 @@ describe('QueueOverlayHeader', () => {
})
it('emits clear history from the menu', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'

View File

@@ -11,28 +11,31 @@
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
}}</span>
<Button
v-tooltip.top="clearAllJobsTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
:disabled="queuedCount === 0"
@click="$emit('clearQueued')"
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip')"
side="top"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
:disabled="queuedCount === 0"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</BaseTooltip>
</div>
<JobHistoryActionsMenu @clear-history="$emit('clearHistory')" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
defineProps<{
headerTitle: string
@@ -45,7 +48,4 @@ defineEmits<{
}>()
const { t } = useI18n()
const clearAllJobsTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
)
</script>

View File

@@ -1,5 +1,6 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
@@ -38,10 +39,10 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
})
}
const mountComponent = (
function renderComponent(
runningTasks: TaskItemImpl[],
pendingTasks: TaskItemImpl[]
) => {
) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
@@ -51,7 +52,9 @@ const mountComponent = (
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
const wrapper = mount(QueueProgressOverlay, {
const user = userEvent.setup()
render(QueueProgressOverlay, {
props: {
expanded: true
},
@@ -68,7 +71,7 @@ const mountComponent = (
}
})
return { wrapper, sidebarTabStore }
return { sidebarTabStore, user }
}
describe('QueueProgressOverlay', () => {
@@ -77,7 +80,7 @@ describe('QueueProgressOverlay', () => {
})
it('shows expanded header with running and queued labels', () => {
const { wrapper } = mountComponent(
renderComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
@@ -85,39 +88,32 @@ describe('QueueProgressOverlay', () => {
[createTask('pending-1', 'pending')]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
expect(screen.getByTestId('expanded-title')).toHaveTextContent(
'2 running, 1 queued'
)
})
it('shows only running label when queued count is zero', () => {
const { wrapper } = mountComponent(
[createTask('running-1', 'in_progress')],
[]
)
renderComponent([createTask('running-1', 'in_progress')], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
)
expect(screen.getByTestId('expanded-title')).toHaveTextContent('1 running')
})
it('shows job queue title when there are no active jobs', () => {
const { wrapper } = mountComponent([], [])
renderComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
expect(screen.getByTestId('expanded-title')).toHaveTextContent('Job Queue')
})
it('toggles the assets sidebar tab when show-assets is clicked', async () => {
const { wrapper, sidebarTabStore } = mountComponent([], [])
const { sidebarTabStore, user } = renderComponent([], [])
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
await user.click(screen.getByTestId('show-assets-button'))
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
await user.click(screen.getByTestId('show-assets-button'))
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
})

View File

@@ -11,103 +11,119 @@
class="flex shrink-0 items-center gap-2"
:class="{ 'ml-2': !showSearch }"
>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.filterBy')"
side="top"
>
<div>
<Popover :show-arrow="false">
<template #button>
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
<Button
v-if="showAssetsAction"
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
</div>
</BaseTooltip>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.sortBy')"
side="top"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<div>
<Popover :show-arrow="false">
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
</div>
</BaseTooltip>
<BaseTooltip
v-if="showAssetsAction"
:text="t('sideToolbar.queueProgressOverlay.showAssets')"
side="top"
>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</BaseTooltip>
</div>
</div>
</template>
@@ -121,7 +137,7 @@ import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
const {
hideShowAssetsAction = false,
@@ -148,15 +164,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
const showAssetsAction = computed(() => !hideShowAssetsAction)
const searchPlaceholderText = computed(
() => searchPlaceholder ?? t('sideToolbar.queueProgressOverlay.searchJobs')

View File

@@ -54,6 +54,10 @@ const i18n = createI18n({
}
})
const BaseTooltipStub = {
template: '<slot />'
}
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
@@ -65,7 +69,9 @@ describe('JobFiltersBar', () => {
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})
@@ -88,7 +94,9 @@ describe('JobFiltersBar', () => {
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})

View File

@@ -124,30 +124,38 @@
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<Button
<BaseTooltip
v-if="state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
:text="t('g.delete')"
side="top"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
<Button
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</BaseTooltip>
<BaseTooltip
v-else-if="
state !== 'completed' &&
state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
:text="t('g.cancel')"
side="top"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
<Button
v-else-if="state === 'completed'"
variant="textonly"
@@ -155,32 +163,40 @@
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<Button
<BaseTooltip
v-if="showMenu !== undefined ? showMenu : true"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
:text="t('g.more')"
side="top"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</BaseTooltip>
</div>
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
<slot name="secondary">{{ rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<Button
<BaseTooltip
v-if="state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
:text="t('g.cancel')"
side="top"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
</div>
</div>
</div>
@@ -195,7 +211,7 @@ import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverP
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
@@ -247,10 +263,6 @@ const {
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => activeDetailsId === jobId)

View File

@@ -682,7 +682,6 @@ export function useErrorGroups(
}
}
} else {
// Groups without cards (e.g. missing_node) surface their title as the message.
messages.add(group.title)
}
}

View File

@@ -20,7 +20,7 @@
</div>
<div
v-if="showDescription"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
class="flex items-center gap-1 text-2xs text-muted-foreground"
>
<span
v-if="

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 PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
@@ -28,54 +29,59 @@ describe('SidebarIcon', () => {
selected: false
}
const mountSidebarIcon = (props: Partial<SidebarIconProps>, options = {}) => {
return mount(SidebarIcon, {
function renderSidebarIcon(props: Partial<SidebarIconProps> = {}) {
const user = userEvent.setup()
const result = render(SidebarIcon, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
},
props: { ...exampleProps, ...props },
...options
props: { ...exampleProps, ...props }
})
return { ...result, user }
}
it('renders button element', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('button.side-bar-button').exists()).toBe(true)
renderSidebarIcon()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('renders icon', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('.side-bar-button-icon').exists()).toBe(true)
const { container } = renderSidebarIcon()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Icon escape hatch: iconify icons have no ARIA role
expect(container.querySelector('.side-bar-button-icon')).not.toBeNull()
})
it('creates badge when iconBadge prop is set', () => {
const badge = '2'
const wrapper = mountSidebarIcon({ iconBadge: badge })
const badgeEl = wrapper.find('.sidebar-icon-badge')
expect(badgeEl.exists()).toBe(true)
expect(badgeEl.text()).toEqual(badge)
renderSidebarIcon({ iconBadge: badge })
expect(screen.getByText(badge)).toBeInTheDocument()
})
it('shows tooltip on hover', async () => {
const tooltipShowDelay = 300
const tooltipText = 'Settings'
const wrapper = mountSidebarIcon({ tooltip: tooltipText })
const { user } = renderSidebarIcon({ tooltip: tooltipText })
const tooltipElBeforeHover = document.querySelector('[role="tooltip"]')
expect(tooltipElBeforeHover).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
// Hover over the icon
await wrapper.trigger('mouseenter')
await new Promise((resolve) => setTimeout(resolve, tooltipShowDelay + 16))
await user.hover(screen.getByRole('button'))
const tooltipElAfterHover = document.querySelector('[role="tooltip"]')
expect(tooltipElAfterHover).not.toBeNull()
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument()
},
{ timeout: 1000 }
)
})
it('sets aria-label attribute when tooltip is provided', () => {
const tooltipText = 'Settings'
const wrapper = mountSidebarIcon({ tooltip: tooltipText })
expect(wrapper.attributes('aria-label')).toEqual(tooltipText)
renderSidebarIcon({ tooltip: tooltipText })
expect(screen.getByRole('button')).toHaveAttribute(
'aria-label',
tooltipText
)
})
})

View File

@@ -31,7 +31,7 @@
v-if="shouldShowBadge"
:class="
cn(
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] leading-[14px] font-medium text-base-foreground',
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-2xs leading-[14px] font-medium text-base-foreground',
badgeClass || '-top-1 -right-1'
)
"
@@ -42,7 +42,7 @@
</slot>
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-[10px]"
class="side-bar-button-label text-center text-2xs"
>{{ st(label, label) }}</span
>
</div>

View File

@@ -85,7 +85,7 @@ const modelDef = props.modelDef
display: inline-block;
text-align: center;
margin: 5px;
font-size: 10px;
font-size: var(--text-2xs);
}
.model_preview_prefix {
font-weight: 700;

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 AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
@@ -11,8 +11,8 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}))
describe('AudioThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(AudioThumbnail, {
function renderThumbnail(props = {}) {
return render(AudioThumbnail, {
props: {
src: '/test-audio.mp3',
...props
@@ -21,15 +21,9 @@ describe('AudioThumbnail', () => {
}
it('renders an audio element with correct src', () => {
const wrapper = mountThumbnail()
const audio = wrapper.find('audio')
expect(audio.exists()).toBe(true)
expect(audio.attributes('src')).toBe('/test-audio.mp3')
})
it('uses BaseThumbnail as container', () => {
const wrapper = mountThumbnail()
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.exists()).toBe(true)
renderThumbnail()
const audio = screen.getByTestId('audio-player')
expect(audio).toBeInTheDocument()
expect(audio).toHaveAttribute('src', '/test-audio.mp3')
})
})

View File

@@ -7,7 +7,13 @@
backgroundRepeat: 'round'
}"
>
<audio controls class="relative w-full" :src="src" @click.stop />
<audio
controls
class="relative w-full"
:src="src"
data-testid="audio-player"
@click.stop
/>
</div>
</BaseThumbnail>
</template>

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 DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
@@ -21,8 +21,8 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}))
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
function renderThumbnail(props = {}) {
return render(DefaultThumbnail, {
props: {
src: '/test-image.jpg',
alt: 'Test Image',
@@ -33,95 +33,69 @@ describe('DefaultThumbnail', () => {
}
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('src')).toBe('/test-image.jpg')
expect(lazyImage.props('alt')).toBe('Test Image')
renderThumbnail()
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', '/test-image.jpg')
expect(img).toHaveAttribute('alt', 'Test Image')
})
it('applies scale transform when hovered', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isHovered: true,
hoverZoom: 10
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
expect(screen.getByRole('img')).toHaveStyle({
transform: 'scale(1.1)'
})
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isHovered: false
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toBeUndefined()
expect(screen.getByRole('img')).not.toHaveStyle({
transform: 'scale(1.1)'
})
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isVideo: true
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('w-full')
expect(classString).toContain('h-full')
expect(classString).toContain('object-cover')
expect(screen.getByRole('img')).toHaveClass(
'w-full',
'h-full',
'object-cover'
)
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isVideo: false
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('max-w-full')
expect(classString).toContain('object-contain')
expect(screen.getByRole('img')).toHaveClass('max-w-full', 'object-contain')
})
it('applies correct styling for webp images', () => {
const wrapper = mountThumbnail({
renderThumbnail({
src: '/test-video.webp',
isVideo: true
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('object-cover')
expect(screen.getByRole('img')).toHaveClass('object-cover')
})
it('image is not draggable', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
renderThumbnail()
expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false')
})
it('applies transition classes', () => {
const wrapper = mountThumbnail()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transform-gpu')
expect(classString).toContain('transition-transform')
expect(classString).toContain('duration-300')
expect(classString).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {
const wrapper = mountThumbnail({
hoverZoom: 20,
isHovered: true
})
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('hoverZoom')).toBe(20)
expect(baseThumbnail.props('isHovered')).toBe(true)
renderThumbnail()
expect(screen.getByRole('img')).toHaveClass(
'transform-gpu',
'transition-transform',
'duration-300',
'ease-out'
)
})
})

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