Compare commits

...

29 Commits

Author SHA1 Message Date
dante01yoon
1407490f3b test: remove flaky SearchFilterChip E2E test
The filter chip add/remove behavior is already covered by
nodeSearchBox.spec.ts which tests removeFilter in the same
v1 (legacy) search box context.
2026-03-28 12:15:39 +09:00
dante01yoon
4e900813ea fix: replace screenshot tests with behavioral assertions in badge E2E
Screenshot tests require Linux golden images that don't exist yet.
Replace with functional assertions: filter chip add/remove and
node library tree badge visibility.
2026-03-28 12:15:39 +09:00
dante01yoon
5e4a82fa29 fix: wait for search input before adding filter in E2E tests 2026-03-28 12:15:39 +09:00
dante01yoon
444b6a4472 fix: migrate missed StatusBadge reference in TopMenuSection 2026-03-28 12:15:39 +09:00
dante01yoon
937e9b3190 test: add functional E2E test for SearchFilterChip remove button 2026-03-28 12:15:39 +09:00
dante01yoon
d512338a2b fix: use comfyPage.toast.closeToasts() instead of custom helper 2026-03-28 12:15:38 +09:00
dante01yoon
8d6a1cb251 refactor: migrate consumers from PrimeVue Badge and StatusBadge to Badge
Replace PrimeVue Badge in TreeExplorerTreeNode, UsageLogsTable, and
SearchFilterChip. Replace StatusBadge with Badge in all consumer
components. Replace PrimeVue Chip with native HTML in SearchFilterChip.
Update customerEventsService severity values to match Badge variants.
Add E2E visual regression tests for badge rendering.
2026-03-28 12:15:38 +09:00
dante01yoon
a4773708fe test: remove flaky NodePreviewCard E2E test
The text-xxxs to text-3xs rename is already validated by Badge
unit tests (twMerge regression), build success, and all other
passing E2E tests. This test was flaky because the first node
in the sampling folder may not always have inputs.
2026-03-28 12:15:38 +09:00
dante01yoon
7be65784f5 fix: match i18n text 'Inputs'/'Outputs' instead of CSS-uppercased 'INPUTS' 2026-03-28 12:15:38 +09:00
dante01yoon
06f3484694 fix: replace screenshot with behavioral assertions in text size rename test 2026-03-28 12:15:38 +09:00
dante01yoon
ffd2503dbe test: add visual regression test for text-2xs rename in NodePreviewCard 2026-03-28 12:15:38 +09:00
Dante
82242f1b00 refactor: add Badge component and fix twMerge font-size detection (#10580)
## Summary
- Rename `text-xxxs`/`text-xxs` to `text-3xs`/`text-2xs` in design
system CSS — fixes `tailwind-merge` incorrectly classifying custom
font-size utilities as color classes, which clobbered text color
- Add `Badge` component with updated severity colors matching Figma
design (white text on colored backgrounds)
- Add Badge stories under `Components/Badges/Badge`
- Add unit tests including twMerge regression coverage

Split from #10438 per review feedback — this PR contains the
foundational Badge component; migration of consumers follows in a
separate PR.

## Test plan
- [x] Unit tests pass (`Badge.test.ts` — 12 tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify Badge stories render correctly in Storybook
- [ ] Verify existing components using `text-2xs`/`text-3xs` render
unchanged

Fixes #10438 (partial)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10580-refactor-add-Badge-component-and-fix-twMerge-font-size-detection-32f6d73d3650810dae7cd0d4af67fd1c)
by [Unito](https://www.unito.io)
2026-03-27 19:23:59 -07:00
Comfy Org PR Bot
f9c334092c 1.43.8 (#10635)
Patch version increment to 1.43.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10635-1-43-8-3316d73d3650815db627c30a83d2b9fc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-27 19:19:07 -07:00
Christian Byrne
04aee0308b feat: add SHA-256 hashed email to GTM dataLayer for sign_up/login events (#10591)
## Summary

Adds SHA-256 hashed user email to GTM dataLayer `sign_up` and `login`
events to improve Meta/LinkedIn Conversions API (CAPI) match rate via
Stape server-side tracking.

## Privacy

- Email is SHA-256 hashed client-side before being pushed to dataLayer —
the raw email never enters the analytics pipeline.
- Email is normalized (trimmed + lowercased) before hashing per
Google/Meta requirements.
- If email is absent (e.g., GitHub OAuth without public email), no
`user_data` entry is pushed.

## Testing

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10591-feat-add-SHA-256-hashed-email-to-GTM-dataLayer-for-sign_up-login-events-3306d73d36508148a321d62810698013)
by [Unito](https://www.unito.io)
2026-03-27 19:18:05 -07:00
Dante
caa6f89436 test(assets): add property-based tests for asset utility functions (#10619)
## Summary

Add property-based tests (using `fast-check`) for asset-related pure
utility functions, complementing existing example-based unit tests with
algebraic invariant checks across thousands of randomized inputs.

Fixes #10617

## Changes

- **What**: 4 new `*.property.test.ts` files covering
`assetFilterUtils`, `assetSortUtils`, `useAssetSelection`, and
`useOutputStacks` — 32 property-based tests total

## Why property-based testing (fast-check)?

### Gap in existing tests

The existing example-based unit tests (53 tests across 3 files) verify
behavior for **hand-picked inputs** — specific category names, known
sort orderings, fixed asset lists. This leaves two blind spots:

1. **Edge-case discovery**: Example tests only cover cases the author
anticipates. Property tests generate hundreds of randomized inputs per
run, probing boundaries the author didn't consider (e.g., empty strings,
single-char names, deeply nested tag paths, assets with `undefined`
metadata fields).

2. **Algebraic invariants**: Certain guarantees should hold for **all**
inputs, not just the handful tested. For example:
- "Filtering always produces a subset" — impossible to violate with 5
examples, easy to violate in production with unexpected metadata shapes
- "Sorting is idempotent" — an unstable sort bug would only surface with
specific duplicate patterns
- "Reconciled selection IDs are always within visible assets" — a
set-intersection bug might only appear with specific overlap patterns
between selection and visible sets

3. **No test coverage for `useOutputStacks`**: The composable had zero
tests before this PR.

### What these tests verify (invariant catalog)

| Module | # Properties | Key invariants |
|--------|-------------|----------------|
| `assetFilterUtils` | 10 | Filter result ⊆ input; `"all"` is identity;
ownership partitions into disjoint my/public; empty constraint is
identity |
| `assetSortUtils` | 8 | Never mutates input; output is permutation of
input; idempotent (sort∘sort = sort); adjacent pairs satisfy comparator;
`"default"` preserves order |
| `useAssetSelection` | 7 | After reconcile: selected ⊆ visible;
reconcile never adds new IDs; superset preserves all; empty visible
clears; `getOutputCount` ≥ 1; `getTotalOutputCount` ≥ len(assets) |
| `useOutputStacks` | 7 | Collapsed count = input count; items reference
input assets; unique keys; selectableAssets length = assetItems length;
no collapsed child flags; reactive ref updates |

### Quantitative impact

Each property runs 100 iterations by default → **3,200 randomized inputs
per test run** vs 53 hand-picked examples in existing tests.

**Coverage delta** (v8, measured against target modules):

| Module | Metric | Before (53 tests) | After (+32 property) | Delta |
|--------|--------|-------------------|---------------------|-------|
| `useAssetSelection.ts` | Branch | 76.92% | 94.87% | **+17.95pp** |
| `useAssetSelection.ts` | Stmts | 82.50% | 90.00% | **+7.50pp** |
| `useAssetSelection.ts` | Lines | 81.69% | 88.73% | **+7.04pp** |
| `useOutputStacks.ts` | Stmts | 0% | 37.50% | **+37.50pp** (new) |
| `useOutputStacks.ts` | Funcs | 0% | 75.00% | **+75.00pp** (new) |
| `assetFilterUtils.ts` | All | 97.5%+ | 97.5%+ | maintained |
| `assetSortUtils.ts` | All | 100% | 100% | maintained |

### Prior art

Follows the established pattern from
`src/platform/workflow/persistence/base/draftCacheV2.property.test.ts`.

## Review Focus

- Are the chosen invariants correct and meaningful (not just
change-detector tests)?
- Are the `fc.Arbitrary` generators representative of real-world asset
data shapes?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10619-test-assets-add-property-based-tests-for-asset-utility-functions-3306d73d3650816985ebcd611bbe0837)
by [Unito](https://www.unito.io)
2026-03-28 10:26:55 +09:00
Dante
c4d0b3c97a feat: fetch publish tag suggestions from hub labels API (#10497)
<img width="1305" height="730" alt="스크린샷 2026-03-28 오전 10 17
30"
src="https://github.com/user-attachments/assets/316fcb72-e749-40da-b29f-05af91f30610"
/>

## Summary
- Replace hardcoded `COMFY_HUB_TAG_OPTIONS` with dynamic fetch from `GET
/hub/labels?type=tag`
- Falls back to the existing static tag list when the API call fails
- Adds `zHubLabelListResponse` Zod schema and `fetchTagLabels` service
method

## Test plan
- [ ] Open publish wizard → verify tag suggestions load from API
- [ ] Disconnect network / use env without hub API → verify hardcoded
fallback tags appear
- [ ] Select and deselect tags → verify behavior unchanged
- [ ] Unit tests pass (`pnpm vitest run` on affected files)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10497-feat-fetch-publish-tag-suggestions-from-hub-labels-API-32e6d73d3650815fb113cf591030d4e8)
by [Unito](https://www.unito.io)
2026-03-28 10:19:21 +09:00
Terry Jia
3eb7c29ea4 test: add image compare widget basic e2e tests (#10597)
## Summary
test: add image compare widget basic e2e tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10597-test-add-image-compare-widget-basic-e2e-tests-3306d73d365081699125e86b6caa7188)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-27 18:15:11 -07:00
Christian Byrne
cc2cb7e89f [chore] Update Ingest API types from cloud@d4d0319 (#10625)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: d4d0319
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10625-chore-Update-Ingest-API-types-from-cloud-d4d0319-3306d73d365081509b1cc5cc9727e4f4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-27 18:13:29 -07:00
Arthur R Longbottom
d2f4d41960 test: make SubscriptionPanel refill date test timezone-agnostic (#10618)
## Summary

Fix timezone-dependent test failure in SubscriptionPanel and add a local
CI script.

## Changes

- **What**: The `renders refill date with literal slashes` test
hardcoded `12/31/24` but the component renders using local timezone
`Date` methods. In UTC-negative timezones, `2024-12-31T00:00:00Z`
renders as Dec 30. Now computes the expected string the same way the
component does.
- **What**: Added `pnpm test:ci:local` script
(`scripts/test-ci-local.sh`) that builds the frontend, starts a ComfyUI
backend with `--multi-user --front-end-root dist`, runs vitest +
Playwright, then cleans up. One command for full local CI.

## Review Focus

This is a test-only change — no production code modified. The
SubscriptionPanel component itself is unchanged; only the test assertion
is made timezone-agnostic.

## E2E Regression Test

Not applicable — this PR fixes a unit test assertion, not a production
bug. No user-facing behavior changed.
2026-03-27 17:31:25 -07:00
Terry Jia
070a5f59fe add basic mask editor tests (#10574)
## Summary
add basic mask editor tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10574-add-basic-mask-editor-tests-32f6d73d36508170b8b2c684be56cd26)
by [Unito](https://www.unito.io)
2026-03-27 16:04:36 -04:00
pythongosssss
7864e780e7 feat: App mode - Rework save flow (#10439)
## Summary

Users were finding the final step of the builder flow
confusing/misleading, with the "choose default mode" not actually saving
the workflow and people losing changes. This updates it to remove
"save"/"set default" as a step in the builder, and changes it to a
distinct action.

## Changes

- **What**: 
- add mode selection tab on footer toolbar
- extract reusable radio group component
- remove setting default mode dialog
- add save/save as/saved dialogs

## Screenshots (if applicable)


https://github.com/user-attachments/assets/c7439c2e-a917-4f2b-b176-f8bb8c10026d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10439-feat-App-mode-Rework-save-flow-32d6d73d3650814781b6c7bbea685a97)
by [Unito](https://www.unito.io)
2026-03-27 12:53:09 -07:00
Alexander Brown
db1257fdb3 test: migrate 8 hard-case component tests from VTU to VTL (Phase 3) (#10493)
## Summary

Phase 3 of the VTL migration: migrate 8 hard-case component tests from
@vue/test-utils to @testing-library/vue (68 tests).

Stacked on #10490.

## Changes

- **What**: Migrate SignInForm, CurrentUserButton, NodeSearchBoxPopover,
BaseThumbnail, JobAssetsList, SelectionToolbox, QueueOverlayExpanded,
PackVersionSelectorPopover from VTU to VTL
- **`wrapper.vm` elimination**: 13 instances across 4 files (5 in
SignInForm, 3 in CurrentUserButton, 3 in PackVersionSelectorPopover, 2
in BaseThumbnail) replaced with user interactions or removed
- **`vm.$emit()` on stubs**: Interactive stubs with `setup(_, { emit })`
expose buttons or closure-based emit functions (QueueOverlayExpanded,
NodeSearchBoxPopover, JobAssetsList)
- **Removed**: 6 change-detector/redundant tests, 3 `@ts-expect-error`
annotations, `PackVersionSelectorVM` interface, `getVM` helper
- **BaseThumbnail**: Removed `useEventListener` mock — real event
handler attaches, `fireEvent.error(img)` triggers error state

## Review Focus

- Interactive stub patterns: `JobAssetsListStub` and `NodeSearchBoxStub`
use closure-based emit functions to trigger parent event handlers
without `vm.$emit`
- SignInForm form submission test fills PrimeVue Form fields via
`userEvent.type` and submits via button click (replaces `vm.onSubmit()`
direct call)
- CurrentUserButton Popover stub tracks open/close state reactively
- JobAssetsList: file-level `eslint-disable` for
`no-container`/`no-node-access`/`prefer-user-event` since stubs lack
ARIA roles and hover tests need `fireEvent`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10493-test-migrate-8-hard-case-component-tests-from-VTU-to-VTL-Phase-3-32e6d73d365081f88097df634606d7e3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 12:37:09 -07:00
Arthur R Longbottom
7e7e2d5647 fix: persist subgraph viewport across navigation and tab switches (#10247)
## Summary

Fix subgraph viewport (zoom + position) drifting when navigating in/out
of subgraphs and switching workflow tabs.

## Problem

Three root causes:
1. **First visit**: `restoreViewport()` silently returned on cache miss,
leaving canvas at stale position
2. **Cross-workflow leakage**: Cache keyed by bare `graphId` — two
workflows with the same subgraph or unsaved workflows shared cache
entries
3. **Stale save on tab switch**: `loadGraphData` and
`changeTracker.restore()` overwrite `canvas.ds` before the async watcher
could save the old viewport

## Solution

1. **Workflow-scoped cache keys**: `${path}#${instanceId}:${graphId}` —
WeakMap assigns unique IDs per workflow object, handling unsaved
workflows with identical paths
2. **`flush: 'sync'` on activeSubgraph watcher**: Fires immediately
during `setGraph()`, BEFORE `loadGraphData`/`changeTracker` can corrupt
`canvas.ds`
3. **Cache miss → rAF fitToBounds**: On first visit, computes bounds
from `graph._nodes` and calls `ds.fitToBounds()` after the browser has
rendered
4. **Workflow switch watcher** (`flush: 'sync'`): Pre-saves viewport
under old workflow identity, suppresses `onNavigated` saves during load
cycle

Key architectural insight: `setGraph()` never touches `canvas.ds`, but
`loadGraphData` and `changeTracker.restore()` both write to it. By using
`flush: 'sync'`, the save happens during `setGraph` (before the
overwrites).

## Review Focus

- `subgraphNavigationStore.ts` — the three fixes and their interaction
- `flush: 'sync'` watchers — critical for correct save timing
- `suppressNavigatedSave` flag — prevents stale saves during async
workflow load

## Breaking Changes

None. Viewport cache is session-only (in-memory LRU). Existing workflows
unaffected.

## Demo Video of Fix


https://github.com/user-attachments/assets/71dd4107-a030-4e68-aa11-47fe00101b25

## Test plan

- [x] Unit: save/restore with workflow-scoped keys
- [x] Unit: cache miss doesn't mutate canvas synchronously
- [x] Unit: navigation integration (enter/exit preserves viewport)
- [x] E2E: first subgraph visit has visible nodes
- [x] Manual: enter subgraph → zoom/pan → exit → re-enter → viewport
restored
- [x] Manual: tab with subgraph → different tab → back → viewport
restored
- [x] Manual: two unsaved workflows → switch between → viewports
isolated

- Fixes #10246
- Related: #8173
<!-- QA_REPORT_SECTION -->
---
## 🔍 Automated QA Report

| | |
|---|---|
| **Status** |  Complete |
| **Report** |
[sno-qa-10247.comfy-qa.pages.dev](https://sno-qa-10247.comfy-qa.pages.dev/)
|
| **CI Run** | [View
workflow](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23373279990)
|

Before/after video recordings with **Behavior Changes** and **Timeline
Comparison** tables.
2026-03-27 19:32:57 +00:00
pythongosssss
dabfc6521e test: Add test to prevent regression of workflow corruption during graph loading (#10623)
## Summary

Adds regression test for
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9531

## Changes

- **What**:  
- registers extension that triggers checkState during
afterConfigureGraph (at this point the workflow data and active graph
are not in sync), previously causing it to overwrite the workflow data
- switches between tabs
- ensures they are not corrupted

Line 35 can be uncommented to cause the test to fail by clearing the
isLoadingGraph flag

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10623-test-Add-test-to-prevent-regression-of-workflow-corruption-during-graph-loading-3306d73d3650815fbf02ef23dfdcddce)
by [Unito](https://www.unito.io)
2026-03-27 11:54:07 -07: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
199 changed files with 6551 additions and 2486 deletions

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "ImageCompare",
"pos": [50, 50],
"size": [400, 350],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "a_images",
"type": "IMAGE",
"link": null
},
{
"name": "b_images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "ImageCompare"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -85,7 +85,10 @@ export class ComfyNodeSearchBox {
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
await this.filterChips
.nth(index)
.getByRole('button', { name: 'Remove' })
.click()
}
/**

View File

@@ -142,6 +142,29 @@ export class AppModeHelper {
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** The builder footer nav containing save/navigation buttons. */
private get builderFooterNav(): Locator {
return this.page
.getByRole('button', { name: 'Exit app builder' })
.locator('..')
}
/** Get a button in the builder footer by its accessible name. */
getFooterButton(name: string | RegExp): Locator {
return this.builderFooterNav.getByRole('button', { name })
}
/** Click the save/save-as button in the builder footer. */
async clickSave() {
await this.getFooterButton(/^Save/).first().click()
await this.comfyPage.nextFrame()
}
/** The "Opens as" popover tab above the builder footer. */
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.

View File

@@ -79,7 +79,8 @@ export const TestIds = {
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
widgetActionsMenu: 'widget-actions-menu',
opensAs: 'builder-opens-as'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'

View File

@@ -0,0 +1,118 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
/** Click the first SaveImage/PreviewImage node on the canvas. */
async function selectOutputNode(comfyPage: ComfyPage) {
const { page } = comfyPage
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
const { page } = comfyPage
await comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await comfyPage.nextFrame()
}
/**
* Enter builder on the default workflow and select I/O.
*
* Loads the default workflow, optionally transforms it (e.g. convert a node
* to subgraph), then enters builder mode and selects inputs + outputs.
*
* @param comfyPage - The page fixture.
* @param getInputNode - Returns the node to click for input selection.
* Receives the KSampler node ref and can transform the graph before
* returning the target node. Defaults to using KSampler directly.
* @returns The node used for input selection.
*/
export async function setupBuilder(
comfyPage: ComfyPage,
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
): Promise<NodeReference> {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
await selectInputWidget(comfyPage, inputNode)
await appMode.goToOutputs()
await selectOutputNode(comfyPage)
return inputNode
}
/**
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
*
* Returns the subgraph node reference for further interaction.
*/
export async function setupSubgraphBuilder(
comfyPage: ComfyPage
): Promise<NodeReference> {
return setupBuilder(comfyPage, async (ksampler) => {
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
expect(promotedNames).toContain('seed')
return subgraphNode
})
}
/** Save the workflow, reopen it, and enter app mode. */
export async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}

View File

@@ -1,89 +1,11 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '../helpers/builderTestUtils'
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -0,0 +1,251 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
// The save-as dialog should appear with filename input and view type selection
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await expect(dialog.getByRole('textbox')).toBeVisible()
await expect(dialog.getByText('Save as')).toBeVisible()
// View type radio group should be present
const radioGroup = dialog.getByRole('radiogroup')
await expect(radioGroup).toBeVisible()
})
test('Save as dialog allows entering filename and saving', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-save-test`
const input = dialog.getByRole('textbox')
await input.fill(workflowName)
// Save button should be enabled now
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeEnabled()
await saveButton.click()
// Success dialog should appear
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
})
test('Save as dialog disables save when filename is empty', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Clear the filename input
const input = dialog.getByRole('textbox')
await input.fill('')
// Save button should be disabled
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeDisabled()
})
test('Builder step navigation works correctly', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Should start at outputs (we ended there in setup)
// Navigate to inputs
await appMode.goToInputs()
// Back button should be disabled on first step
const backButton = appMode.getFooterButton('Back')
await expect(backButton).toBeDisabled()
// Next button should be enabled
const nextButton = appMode.getFooterButton('Next')
await expect(nextButton).toBeEnabled()
// Navigate forward
await appMode.next()
// Back button should now be enabled
await expect(backButton).toBeEnabled()
// Navigate to preview (last step)
await appMode.next()
// Next button should be disabled on last step
await expect(nextButton).toBeDisabled()
})
test('Escape key exits builder mode', async ({ comfyPage }) => {
const { page } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Verify builder toolbar is visible
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
// Press Escape
await page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Builder toolbar should be gone
await expect(toolbar).not.toBeVisible()
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
await appMode.exitBuilder()
await expect(toolbar).not.toBeVisible()
})
test('Save button directly saves for previously saved workflow', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-direct-save`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Now click save again — should save directly
await appMode.clickSave()
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
})
test('Split button chevron opens save-as for saved workflow', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-split-btn`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Click the chevron dropdown trigger
const chevronButton = appMode.getFooterButton('Save as')
await chevronButton.click()
// "Save as" menu item should appear
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
await expect(menuItem).toBeVisible({ timeout: 5000 })
await menuItem.click()
// Save-as dialog should appear
const newSaveAsDialog = page.getByRole('dialog')
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
timeout: 5000
})
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// Without selecting any outputs, click the save button
// It should trigger the connect-output popover
await appMode.clickSave()
// The popover should show a message about connecting outputs
await expect(
page.getByText('Connect an output', { exact: false })
).toBeVisible({ timeout: 5000 })
})
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// App should be selected by default
const appRadio = dialog.getByRole('radio', { name: /App/ })
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
// Click Node graph option
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
await graphRadio.click()
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
})
})

View File

@@ -0,0 +1,66 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe(
'Change Tracker - isLoadingGraph guard',
{ tag: '@workflow' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents checkState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces checkState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a checkState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
afterConfigureGraph() {
const workflow = (window.app!.extensionManager as WorkspaceStore)
.workflow.activeWorkflow
if (!workflow) throw new Error('No workflow found')
// Bypass the guard to reproduce the corruption bug:
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.checkState()
}
})
})
// Create tab 1: blank workflow (0 nodes)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to tab 0 (workflow-a).
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// switch to blank tab and back to verify no corruption
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
await tab1.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// switch again and verify no corruption
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
})
}
)

View File

@@ -0,0 +1,81 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Image Compare', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
await comfyPage.vueNodes.waitForNodes()
})
function createTestImageDataUrl(label: string, color: string): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">` +
`<rect width="200" height="200" fill="${color}"/>` +
`<text x="50%" y="50%" fill="white" font-size="24" ` +
`text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
async function setImageCompareValue(
comfyPage: ComfyPage,
value: { beforeImages: string[]; afterImages: string[] }
) {
await comfyPage.page.evaluate(
({ value }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = value
widget.callback?.(value)
}
},
{ value }
)
await comfyPage.nextFrame()
}
test(
'Shows empty state when no images are set',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node).toContainText('No images to compare')
await expect(node.locator('img')).toHaveCount(0)
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
}
)
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const beforeUrl = createTestImageDataUrl('Before', '#c00')
const afterUrl = createTestImageDataUrl('After', '#00c')
await setImageCompareValue(comfyPage, {
beforeImages: [beforeUrl],
afterImages: [afterUrl]
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
await expect(beforeImg).toBeVisible()
await expect(afterImg).toBeVisible()
const handle = node.locator('[role="presentation"]')
await expect(handle).toBeVisible()
expect(
await handle.evaluate((el) => (el as HTMLElement).style.left)
).toBe('50%')
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
await expect(node).toHaveScreenshot('image-compare-default-50.png')
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,92 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Mask Editor', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { imagePreview } = await loadImageOnNode(comfyPage)
// Hover over the image panel to reveal action buttons
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
}
)
test(
'opens mask editor from context menu',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-header')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await contextMenu.getByText('Open in Mask Editor').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await expect(dialog).toHaveScreenshot(
'mask-editor-dialog-from-context-menu.png'
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

View File

@@ -110,7 +110,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.removeFilter(0)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler', {
exact: true
})

View File

@@ -0,0 +1,114 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
function hasVisibleNodeInViewport() {
const canvas = window.app!.canvas
if (!canvas?.graph?._nodes?.length) return false
const ds = canvas.ds
const cw = canvas.canvas.width / window.devicePixelRatio
const ch = canvas.canvas.height / window.devicePixelRatio
const visLeft = -ds.offset[0]
const visTop = -ds.offset[1]
const visRight = visLeft + cw / ds.scale
const visBottom = visTop + ch / ds.scale
for (const node of canvas.graph._nodes) {
const [nx, ny] = node.pos
const [nw, nh] = node.size
if (
nx + nw > visLeft &&
nx < visRight &&
ny + nh > visTop &&
ny < visBottom
)
return true
}
return false
}
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph!
const sgNode = graph._nodes.find((n) =>
'isSubgraphNode' in n
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
: false
) as unknown as { subgraph?: typeof graph } | undefined
if (!sgNode?.subgraph) throw new Error('No subgraph node')
canvas.setGraph(sgNode.subgraph)
})
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('first visit fits viewport to subgraph nodes (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('viewport is restored when returning to root (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const rootViewport = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
offset: [
expect.closeTo(rootViewport.offset[0], 0),
expect.closeTo(rootViewport.offset[1], 0)
]
})
})
})

View File

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

View File

@@ -25,11 +25,11 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--text-3xs: 0.5625rem;
--text-3xs--line-height: calc(1 / 0.5625);
/* Font Families */
--font-inter: 'Inter', sans-serif;
@@ -230,6 +230,7 @@
--interface-builder-mode-background: var(--color-ocean-300);
--interface-builder-mode-button-background: var(--color-ocean-600);
--interface-builder-mode-button-foreground: var(--color-white);
--interface-builder-mode-footer-background: var(--color-ocean-900);
--nav-background: var(--color-white);
@@ -373,6 +374,7 @@
--interface-builder-mode-background: var(--color-ocean-900);
--interface-builder-mode-button-background: var(--color-ocean-600);
--interface-builder-mode-button-foreground: var(--color-white);
--interface-builder-mode-footer-background: var(--color-ocean-900);
--nav-background: var(--color-charcoal-800);
@@ -516,6 +518,9 @@
--color-interface-builder-mode-button-foreground: var(
--interface-builder-mode-button-foreground
);
--color-interface-builder-mode-footer-background: var(
--interface-builder-mode-footer-background
);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);

View File

@@ -16,6 +16,7 @@ export type {
AssetCreated,
AssetCreatedWritable,
AssetDownloadResponse,
AssetInfo,
AssetMetadataResponse,
AssetTagHistogramResponse,
AssetUpdated,
@@ -38,6 +39,11 @@ export type {
CheckAssetByHashError,
CheckAssetByHashErrors,
CheckAssetByHashResponses,
CheckHubUsernameData,
CheckHubUsernameError,
CheckHubUsernameErrors,
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClaimInviteCodeData,
ClaimInviteCodeError,
ClaimInviteCodeErrors,
@@ -62,7 +68,19 @@ export type {
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
CreateHubAssetUploadUrlResponse,
CreateHubAssetUploadUrlResponses,
CreateHubProfileData,
CreateHubProfileError,
CreateHubProfileErrors,
CreateHubProfileRequest,
CreateHubProfileResponse,
CreateHubProfileResponses,
CreateInviteRequest,
CreateSecretData,
CreateSecretError,
@@ -111,6 +129,11 @@ export type {
DeleteAssetErrors,
DeleteAssetResponse,
DeleteAssetResponses,
DeleteHubWorkflowData,
DeleteHubWorkflowError,
DeleteHubWorkflowErrors,
DeleteHubWorkflowResponse,
DeleteHubWorkflowResponses,
DeleteSecretData,
DeleteSecretError,
DeleteSecretErrors,
@@ -212,6 +235,16 @@ export type {
GetGlobalSubgraphsErrors,
GetGlobalSubgraphsResponse,
GetGlobalSubgraphsResponses,
GetHubProfileByUsernameData,
GetHubProfileByUsernameError,
GetHubProfileByUsernameErrors,
GetHubProfileByUsernameResponse,
GetHubProfileByUsernameResponses,
GetHubWorkflowData,
GetHubWorkflowError,
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetInviteCodeStatusData,
GetInviteCodeStatusError,
GetInviteCodeStatusErrors,
@@ -250,11 +283,21 @@ export type {
GetModelsInFolderErrors,
GetModelsInFolderResponse,
GetModelsInFolderResponses,
GetMyHubProfileData,
GetMyHubProfileError,
GetMyHubProfileErrors,
GetMyHubProfileResponse,
GetMyHubProfileResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
GetPaymentPortalResponse,
GetPaymentPortalResponses,
GetPublishedWorkflowData,
GetPublishedWorkflowError,
GetPublishedWorkflowErrors,
GetPublishedWorkflowResponse,
GetPublishedWorkflowResponses,
GetRawLogsData,
GetRawLogsError,
GetRawLogsErrors,
@@ -305,11 +348,30 @@ export type {
GetWorkspaceResponses,
GlobalSubgraphData,
GlobalSubgraphInfo,
HubAssetUploadUrlRequest,
HubAssetUploadUrlResponse,
HubLabelInfo,
HubLabelListResponse,
HubProfile,
HubProfileSummary,
HubUsernameCheckResponse,
HubWorkflowDetail,
HubWorkflowListResponse,
HubWorkflowSummary,
HubWorkflowTemplateEntry,
ImportPublishedAssetsData,
ImportPublishedAssetsError,
ImportPublishedAssetsErrors,
ImportPublishedAssetsRequest,
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InviteCodeClaimResponse,
InviteCodeStatusResponse,
JobStatusResponse,
JwkKey,
JwksResponse,
LabelRef,
LeaveWorkspaceData,
LeaveWorkspaceError,
LeaveWorkspaceErrors,
@@ -322,6 +384,21 @@ export type {
ListAssetsResponse2,
ListAssetsResponses,
ListAssetsResponseWritable,
ListHubLabelsData,
ListHubLabelsError,
ListHubLabelsErrors,
ListHubLabelsResponse,
ListHubLabelsResponses,
ListHubWorkflowIndexData,
ListHubWorkflowIndexError,
ListHubWorkflowIndexErrors,
ListHubWorkflowIndexResponse,
ListHubWorkflowIndexResponses,
ListHubWorkflowsData,
ListHubWorkflowsError,
ListHubWorkflowsErrors,
ListHubWorkflowsResponse,
ListHubWorkflowsResponses,
ListInvitesResponse,
ListMembersResponse,
ListSecretsData,
@@ -376,6 +453,11 @@ export type {
PlanAvailability,
PlanAvailabilityReason,
PlanSeatSummary,
PostAssetsFromWorkflowData,
PostAssetsFromWorkflowError,
PostAssetsFromWorkflowErrors,
PostAssetsFromWorkflowResponse,
PostAssetsFromWorkflowResponses,
PreviewPlanInfo,
PreviewSubscribeData,
PreviewSubscribeError,
@@ -384,6 +466,13 @@ export type {
PreviewSubscribeResponse,
PreviewSubscribeResponse2,
PreviewSubscribeResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
PublishHubWorkflowErrors,
PublishHubWorkflowRequest,
PublishHubWorkflowResponse,
PublishHubWorkflowResponses,
RawLogsResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
@@ -455,6 +544,12 @@ export type {
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateSecretData,
UpdateSecretError,
UpdateSecretErrors,
@@ -486,6 +581,8 @@ export type {
UserResponse,
ValidationError,
ValidationResult,
WorkflowApiAssetsRequest,
WorkflowApiAssetsResponse,
WorkflowForkedFrom,
WorkflowListResponse,
WorkflowResponse,

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,207 @@
import { z } from 'zod'
export const zHubUsernameCheckResponse = z.object({
username: z.string(),
available: z.boolean(),
suggestions: z.array(z.string()).optional(),
validation_error: z.string().optional()
})
export const zHubAssetUploadUrlResponse = z.object({
upload_url: z.string(),
public_url: z.string(),
token: z.string()
})
export const zHubAssetUploadUrlRequest = z.object({
filename: z.string(),
content_type: z.string()
})
export const zPublishHubWorkflowRequest = z.object({
username: z.string(),
name: z.string(),
workflow_filename: z.string(),
asset_ids: z.array(z.string()),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
models: z.array(z.string()).optional(),
custom_nodes: z.array(z.string()).optional(),
tutorial_url: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_token_or_url: z.string().optional(),
thumbnail_comparison_token_or_url: z.string().optional(),
sample_image_tokens_or_urls: z.array(z.string()).optional()
})
export const zAssetInfo = z.object({
id: z.string(),
name: z.string(),
preview_url: z.string(),
storage_url: z.string(),
model: z.boolean(),
public: z.boolean(),
in_library: z.boolean()
})
export const zHubProfileSummary = z.object({
username: z.string(),
display_name: z.string().optional(),
avatar_url: z.string().optional()
})
export const zLabelRef = z.object({
name: z.string(),
display_name: z.string()
})
export const zHubWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
name: z.string(),
description: z.string().optional(),
tags: z.array(zLabelRef).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_url: z.string().optional(),
thumbnail_comparison_url: z.string().optional(),
models: z.array(zLabelRef).optional(),
custom_nodes: z.array(zLabelRef).optional(),
tutorial_url: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
sample_image_urls: z.array(z.string()).optional(),
publish_time: z.string().datetime().nullish(),
workflow_json: z.record(z.unknown()),
assets: z.array(zAssetInfo),
profile: zHubProfileSummary
})
export const zHubWorkflowSummary = z.object({
share_id: z.string(),
name: z.string(),
description: z.string().optional(),
tags: z.array(zLabelRef).optional(),
models: z.array(zLabelRef).optional(),
custom_nodes: z.array(zLabelRef).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_url: z.string().optional(),
thumbnail_comparison_url: z.string().optional(),
publish_time: z.string().datetime().nullish(),
profile: zHubProfileSummary,
metadata: z.record(z.unknown()).optional(),
tutorial_url: z.string().optional(),
sample_image_urls: z.array(z.string()).optional()
})
export const zHubWorkflowListResponse = z.object({
workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])),
next_cursor: z.string().optional()
})
export const zHubLabelInfo = z.object({
name: z.string(),
display_name: z.string(),
description: z.string().optional(),
type: z.enum(['tag', 'model', 'custom_node'])
})
export const zHubLabelListResponse = z.object({
labels: z.array(zHubLabelInfo)
})
export const zHubWorkflowTemplateEntry = z.object({
name: z.string(),
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
models: z.array(z.string()).optional(),
requiresCustomNodes: z.array(z.string()).optional(),
thumbnailVariant: z.string().optional(),
mediaType: z.string().optional(),
mediaSubtype: z.string().optional(),
size: z.number().optional(),
vram: z.number().optional(),
openSource: z.boolean().optional(),
profile: zHubProfileSummary.optional(),
tutorialUrl: z.string().optional(),
logos: z.array(z.record(z.unknown())).optional(),
date: z.string().optional(),
io: z
.object({
inputs: z.array(z.record(z.unknown())).optional(),
outputs: z.array(z.record(z.unknown())).optional()
})
.optional(),
includeOnDistributions: z.array(z.string()).optional(),
thumbnailUrl: z.string().optional(),
thumbnailComparisonUrl: z.string().optional(),
shareId: z.string().optional(),
extendedDescription: z.string().optional(),
metaDescription: z.string().optional(),
howToUse: z.array(z.string()).optional(),
suggestedUseCases: z.array(z.string()).optional(),
faqItems: z
.array(
z.object({
question: z.string(),
answer: z.string()
})
)
.optional(),
contentTemplate: z.string().optional()
})
export const zUpdateHubProfileRequest = z.object({
display_name: z.string().optional(),
description: z.string().optional(),
avatar_token: z.string().nullish(),
website_urls: z.array(z.string()).optional()
})
export const zCreateHubProfileRequest = z.object({
workspace_id: z.string(),
username: z.string(),
display_name: z.string().optional(),
description: z.string().optional(),
avatar_token: z.string().optional(),
website_urls: z.array(z.string()).optional()
})
export const zHubProfile = z.object({
username: z.string(),
display_name: z.string().optional(),
description: z.string().optional(),
avatar_url: z.string().optional(),
website_urls: z.array(z.string()).optional()
})
export const zImportPublishedAssetsResponse = z.object({
assets: z.array(zAssetInfo)
})
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string())
})
export const zPublishedWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
name: z.string(),
listed: z.boolean(),
publish_time: z.string().datetime().nullish(),
workflow_json: z.record(z.unknown()),
assets: z.array(zAssetInfo)
})
export const zWorkflowApiAssetsResponse = z.object({
assets: z.array(zAssetInfo)
})
export const zWorkflowApiAssetsRequest = z.object({
workflow_api_json: z.record(z.unknown())
})
export const zForkWorkflowRequest = z.object({
source_version: z.number().int(),
name: z.string().optional()
@@ -992,7 +1193,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
include_public: z.boolean().optional().default(true)
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
})
.optional()
})
@@ -1234,6 +1437,28 @@ export const zCheckAssetByHashData = z.object({
query: z.never().optional()
})
export const zPostAssetsFromWorkflowData = z.object({
body: zWorkflowApiAssetsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zPostAssetsFromWorkflowResponse = zWorkflowApiAssetsResponse
export const zImportPublishedAssetsData = z.object({
body: zImportPublishedAssetsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Successfully imported assets
*/
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
export const zListSecretsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1633,6 +1858,13 @@ export const zCreateDeletionRequestData = z.object({
query: z.never().optional()
})
/**
* Created - deletion request created or already exists
*/
export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -1928,3 +2160,171 @@ export const zForkWorkflowData = z.object({
* Workflow forked successfully
*/
export const zForkWorkflowResponse = zWorkflowResponse
export const zCreateHubProfileData = z.object({
body: zCreateHubProfileRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Hub profile created
*/
export const zCreateHubProfileResponse = zHubProfile
export const zGetMyHubProfileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Hub profile
*/
export const zGetMyHubProfileResponse = zHubProfile
export const zCheckHubUsernameData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
username: z.string()
})
})
/**
* Username availability result
*/
export const zCheckHubUsernameResponse = zHubUsernameCheckResponse
export const zGetHubProfileByUsernameData = z.object({
body: z.never().optional(),
path: z.object({
username: z.string()
}),
query: z.never().optional()
})
/**
* Hub profile
*/
export const zGetHubProfileByUsernameResponse = zHubProfile
export const zUpdateHubProfileData = z.object({
body: zUpdateHubProfileRequest,
path: z.object({
username: z.string()
}),
query: z.never().optional()
})
/**
* Hub profile updated
*/
export const zUpdateHubProfileResponse = zHubProfile
export const zCreateHubAssetUploadUrlData = z.object({
body: zHubAssetUploadUrlRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Presigned upload URL and token
*/
export const zCreateHubAssetUploadUrlResponse = zHubAssetUploadUrlResponse
export const zListHubLabelsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
type: z.enum(['tag', 'model', 'custom_node']).optional()
})
.optional()
})
/**
* List of labels
*/
export const zListHubLabelsResponse = zHubLabelListResponse
export const zListHubWorkflowsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
cursor: z.string().optional(),
limit: z.number().int().gte(1).lte(100).optional().default(20),
search: z.string().optional(),
tag: z.string().optional(),
username: z.string().optional(),
detail: z.boolean().optional().default(false)
})
.optional()
})
/**
* Paginated list of hub workflows
*/
export const zListHubWorkflowsResponse = zHubWorkflowListResponse
export const zPublishHubWorkflowData = z.object({
body: zPublishHubWorkflowRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Workflow published to hub
*/
export const zPublishHubWorkflowResponse = zHubWorkflowDetail
export const zListHubWorkflowIndexData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* List of hub workflow template entries
*/
export const zListHubWorkflowIndexResponse = z.array(zHubWorkflowTemplateEntry)
export const zDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully unpublished
*/
export const zDeleteHubWorkflowResponse = z.void()
export const zGetHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Hub workflow detail
*/
export const zGetHubWorkflowResponse = zHubWorkflowDetail
export const zGetPublishedWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Published workflow details with asset statuses
*/
export const zGetPublishedWorkflowResponse = zPublishedWorkflowDetail

View File

@@ -7,17 +7,15 @@
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
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 +127,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
}))

View File

@@ -82,7 +82,7 @@
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
<Badge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
@@ -141,7 +141,7 @@ 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 Badge from '@/components/common/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'

View File

@@ -44,7 +44,7 @@ const renderActionbar = (showRunProgressBar: boolean) => {
name: 'Panel',
template: '<div><slot /></div>'
},
StatusBadge: true,
Badge: true,
ComfyRunButton: {
name: 'ComfyRunButton',
template: '<button type="button">Run</button>'

View File

@@ -59,7 +59,7 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
<Badge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
@@ -104,7 +104,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'

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,63 +0,0 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
<span class="inline-flex items-center gap-2">
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
<i
aria-hidden="true"
class="icon-[lucide--circle-check-big] size-4 text-green-500"
/>
</span>
</template>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppBody')
: $t('builderToolbar.defaultModeAppliedGraphBody')
}}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppPrompt')
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
}}
</p>
<template #footer>
<template v-if="appliedAsApp">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
<template v-else>
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
{{ $t('builderToolbar.exitToWorkflow') }}
</Button>
</template>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
appliedAsApp: boolean
}>()
defineEmits<{
viewApp: []
close: []
exitToWorkflow: []
}>()
</script>

View File

@@ -1,7 +1,5 @@
<template>
<div
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
>
<div class="flex w-full min-w-116 flex-col rounded-2xl bg-base-background">
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"

View File

@@ -11,11 +11,11 @@ import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockShowDialog = vi.hoisted(() => vi.fn())
const mockSave = vi.hoisted(() => vi.fn())
const mockSaveAs = vi.hoisted(() => vi.fn())
const mockState = {
mode: 'builder:select' as AppMode,
settingView: false
mode: 'builder:inputs' as AppMode
}
vi.mock('@/composables/useAppMode', () => ({
@@ -42,10 +42,37 @@ vi.mock('@/stores/dialogStore', () => ({
})
}))
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
useAppSetDefaultView: () => ({
settingView: computed(() => mockState.settingView),
showDialog: mockShowDialog
const mockActiveWorkflow = ref<{
isTemporary: boolean
initialMode?: string
isModified?: boolean
changeTracker?: { checkState: () => void }
} | null>({
isTemporary: true,
initialMode: 'app'
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { extra: {} } }
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('./useBuilderSave', () => ({
useBuilderSave: () => ({
save: mockSave,
saveAs: mockSaveAs,
isSaving: { value: false }
})
}))
@@ -55,7 +82,17 @@ const i18n = createI18n({
messages: {
en: {
builderMenu: { exitAppBuilder: 'Exit app builder' },
g: { back: 'Back', next: 'Next' }
builderToolbar: {
viewApp: 'View app',
saveAs: 'Save as',
app: 'App',
nodeGraph: 'Node graph'
},
builderFooter: {
opensAsApp: 'Open as an {mode}',
opensAsGraph: 'Open as a {mode}'
},
g: { back: 'Back', next: 'Next', save: 'Save' }
}
}
})
@@ -66,7 +103,7 @@ describe('BuilderFooterToolbar', () => {
vi.clearAllMocks()
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockState.settingView = false
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
})
function renderComponent() {
@@ -75,7 +112,11 @@ describe('BuilderFooterToolbar', () => {
render(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: { Button: false }
stubs: {
Button: false,
BuilderOpensAsPopover: true,
ConnectOutputPopover: { template: '<div><slot /></div>' }
}
}
})
@@ -88,18 +129,12 @@ describe('BuilderFooterToolbar', () => {
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled()
})
it('enables back on the second step', () => {
it('enables back on the arrange step', () => {
mockState.mode = 'builder:arrange'
renderComponent()
expect(screen.getByRole('button', { name: /back/i })).toBeEnabled()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
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
@@ -127,17 +162,55 @@ describe('BuilderFooterToolbar', () => {
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
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 { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /exit app builder/i }))
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
it('calls setMode app on view app click', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /view app/i }))
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('shows "Save as" when workflow is temporary', () => {
mockActiveWorkflow.value = { isTemporary: true }
renderComponent()
expect(screen.getByRole('button', { name: 'Save as' })).toBeDefined()
})
it('shows "Save" when workflow is saved', () => {
mockActiveWorkflow.value = { isTemporary: false }
renderComponent()
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
it('calls saveAs when workflow is temporary', async () => {
mockActiveWorkflow.value = { isTemporary: true }
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Save as' }))
expect(mockSaveAs).toHaveBeenCalledOnce()
})
it('calls save when workflow is saved and modified', async () => {
mockActiveWorkflow.value = { isTemporary: false, isModified: true }
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSave).toHaveBeenCalledOnce()
})
it('disables save button when workflow has no unsaved changes', () => {
mockActiveWorkflow.value = { isTemporary: false, isModified: false }
renderComponent()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('does not call save when no outputs', async () => {
mockHasOutputs.value = false
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Save as' }))
expect(mockSave).not.toHaveBeenCalled()
expect(mockSaveAs).not.toHaveBeenCalled()
})
})

View File

@@ -1,46 +1,160 @@
<template>
<nav
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
<div
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 flex-col items-center"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
<!-- "Opens as" attachment tab -->
<BuilderOpensAsPopover
v-if="isSaved"
:is-app-mode="isAppMode"
@select="onSetDefaultView"
/>
<!-- Main toolbar -->
<nav
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
</nav>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button variant="secondary" size="lg" @click="onViewApp">
{{ t('builderToolbar.viewApp') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<Button size="lg" :class="cn('w-24', disabledSaveClasses)">
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
{{ t('builderToolbar.saveAs') }}
</Button>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import { useBuilderSave } from './useBuilderSave'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const { isBuilderMode } = useAppMode()
const workflowStore = useWorkflowStore()
const { isBuilderMode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(appModeStore)
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
const {
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext
} = useBuilderSteps({
hasOutputs
})
const { save, saveAs } = useBuilderSave()
const isSaved = computed(
() => workflowStore.activeWorkflow?.isTemporary === false
)
const activeSaveClasses =
'bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/80'
const disabledSaveClasses =
'bg-secondary-background text-muted-foreground/50 disabled:opacity-100'
const isModified = computed(
() => workflowStore.activeWorkflow?.isModified === true
)
const isAppMode = computed(
() => workflowStore.activeWorkflow?.initialMode !== 'graph'
)
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
@@ -60,4 +174,14 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
function onExitBuilder() {
appModeStore.exitBuilder()
}
function onViewApp() {
setMode('app')
}
function onSetDefaultView(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
setWorkflowDefaultView(workflow, openAsApp)
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<PopoverRoot>
<PopoverAnchor as-child>
<div
data-testid="builder-opens-as"
class="flex h-8 min-w-64 items-center justify-center gap-2 rounded-t-2xl bg-interface-builder-mode-footer-background px-4 text-sm text-interface-builder-mode-button-foreground"
>
<i :class="cn(currentModeIcon, 'size-4')" aria-hidden="true" />
<i18n-t
:keypath="
isAppMode
? 'builderFooter.opensAsApp'
: 'builderFooter.opensAsGraph'
"
tag="span"
>
<template #mode>
<PopoverTrigger as-child>
<Button
class="-ml-0.5 h-6 gap-1 rounded-md border-none bg-transparent px-1.5 text-sm text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/70"
>
{{
isAppMode
? t('builderToolbar.app').toLowerCase()
: t('builderToolbar.nodeGraph').toLowerCase()
}}
<i
class="icon-[lucide--chevron-down] size-3.5"
aria-hidden="true"
/>
</Button>
</PopoverTrigger>
</template>
</i18n-t>
<PopoverPortal>
<PopoverContent
side="top"
:side-offset="5"
:collision-padding="10"
class="z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<ViewTypeRadioGroup
:model-value="isAppMode"
:aria-label="t('builderToolbar.defaultViewLabel')"
size="sm"
@update:model-value="$emit('select', $event)"
/>
</PopoverContent>
</PopoverPortal>
</div>
</PopoverAnchor>
</PopoverRoot>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
const { isAppMode } = defineProps<{
isAppMode: boolean
}>()
defineEmits<{
select: [openAsApp: boolean]
}>()
const { t } = useI18n()
const currentModeIcon = computed(() =>
isAppMode ? 'icon-[lucide--app-window]' : 'icon-[comfy--workflow]'
)
</script>

View File

@@ -0,0 +1,71 @@
<template>
<BuilderDialog @close="emit('close')">
<template #title>
{{ $t('builderToolbar.saveAs') }}
</template>
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="focus-visible:ring-ring flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground"
@keydown.enter="
filename.trim() && emit('save', filename.trim(), openAsApp)
"
/>
</div>
<div class="flex flex-col gap-2">
<label :id="radioGroupLabelId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
<ViewTypeRadioGroup
v-model="openAsApp"
:aria-labelledby="radioGroupLabelId"
/>
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="emit('save', filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref, useId } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
const { defaultFilename, defaultOpenAsApp = true } = defineProps<{
defaultFilename: string
defaultOpenAsApp?: boolean
}>()
const emit = defineEmits<{
save: [filename: string, openAsApp: boolean]
close: []
}>()
const inputId = useId()
const radioGroupLabelId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(defaultOpenAsApp)
</script>

View File

@@ -23,55 +23,21 @@
<StepLabel :step />
</button>
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</ConnectOutputPopover>
<button
v-else
:class="
cn(
stepClasses,
activeStep === 'setDefaultView'
? 'bg-interface-builder-mode-background'
: 'bg-transparent hover:bg-secondary-background'
)
"
@click="navigateToStep('setDefaultView')"
>
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
<div
v-if="index < steps.length - 1"
class="mx-1 h-px w-4 bg-border-default"
role="separator"
/>
<StepLabel :step="defaultViewStep" />
</button>
</template>
</div>
</nav>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
@@ -79,9 +45,7 @@ import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
const { activeStep, navigateToStep } = useBuilderSteps()
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
@@ -107,11 +71,5 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
icon: 'icon-[lucide--layout-panel-left]'
}
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -5,7 +5,8 @@
</PopoverTrigger>
<PopoverContent
side="bottom"
:side-offset="8"
align="end"
:side-offset="18"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
>

View File

@@ -1,97 +0,0 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
{{ $t('builderToolbar.defaultViewTitle') }}
</template>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in viewTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
:class="
cn(
itemClasses,
openAsApp === option.value && 'bg-secondary-background'
)
"
variant="textonly"
@click="openAsApp = option.value"
>
<div
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
>
<i :class="cn(option.icon, 'size-4')" />
</div>
<div class="mx-2 flex flex-1 flex-col items-start">
<span class="text-sm font-medium text-base-foreground">
{{ option.title }}
</span>
<span class="text-xs text-muted-foreground">
{{ option.subtitle }}
</span>
</div>
<i
v-if="openAsApp === option.value"
class="icon-[lucide--check] size-4 text-base-foreground"
/>
</Button>
</div>
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
{{ $t('g.apply') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { initialOpenAsApp = true } = defineProps<{
initialOpenAsApp?: boolean
}>()
defineEmits<{
apply: [openAsApp: boolean]
close: []
}>()
const openAsApp = ref(initialOpenAsApp)
const viewTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',
title: t('builderToolbar.app'),
subtitle: t('builderToolbar.appDescription')
},
{
value: false,
icon: 'icon-[comfy--workflow]',
title: t('builderToolbar.nodeGraph'),
subtitle: t('builderToolbar.nodeGraphDescription')
}
]
const itemClasses =
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div role="radiogroup" v-bind="$attrs" :class="cn('flex flex-col', gapClass)">
<Button
v-for="option in viewTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="modelValue === option.value"
:class="
cn(
'flex cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background',
heightClass,
modelValue === option.value && 'bg-secondary-background'
)
"
variant="textonly"
@click="
modelValue !== option.value && emit('update:modelValue', option.value)
"
>
<div
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
>
<i :class="cn(option.icon, 'size-4')" aria-hidden="true" />
</div>
<div class="mx-2 flex flex-1 flex-col items-start">
<span class="text-sm font-medium text-base-foreground">
{{ option.title }}
</span>
<span class="text-xs text-muted-foreground">
{{ option.subtitle }}
</span>
</div>
<i
v-if="modelValue === option.value"
class="icon-[lucide--check] size-4 text-base-foreground"
aria-hidden="true"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { size = 'md' } = defineProps<{
modelValue: boolean
size?: 'sm' | 'md'
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const viewTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',
title: t('builderToolbar.app'),
subtitle: t('builderToolbar.appDescription')
},
{
value: false,
icon: 'icon-[comfy--workflow]',
title: t('builderToolbar.nodeGraph'),
subtitle: t('builderToolbar.nodeGraphDescription')
}
]
const heightClass = size === 'sm' ? 'h-12' : 'h-14'
const gapClass = size === 'sm' ? 'gap-1' : 'gap-2'
</script>

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLoadedWorkflow } from '@/utils/__tests__/litegraphTestUtils'
import type { setWorkflowDefaultView as SetWorkflowDefaultViewFn } from './builderViewOptions'
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackDefaultViewSet: mockTrackDefaultViewSet })
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { extra: {} } }
}))
describe('setWorkflowDefaultView', () => {
let setWorkflowDefaultView: typeof SetWorkflowDefaultViewFn
let app: { rootGraph: { extra: Record<string, unknown> } }
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('./builderViewOptions')
setWorkflowDefaultView = mod.setWorkflowDefaultView
app = (await import('@/scripts/app')).app as typeof app
app.rootGraph.extra = {}
})
it('sets initialMode to app when openAsApp is true', () => {
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
setWorkflowDefaultView(workflow, true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
setWorkflowDefaultView(workflow, false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(app.rootGraph.extra.linearMode).toBe(true)
setWorkflowDefaultView(workflow, false)
expect(app.rootGraph.extra.linearMode).toBe(false)
})
it('calls changeTracker.checkState', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
})
it('tracks telemetry with correct default_view', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
default_view: 'app'
})
setWorkflowDefaultView(workflow, false)
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
default_view: 'graph'
})
})
})

View File

@@ -0,0 +1,16 @@
import { useTelemetry } from '@/platform/telemetry'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
export function setWorkflowDefaultView(
workflow: LoadedComfyWorkflow,
openAsApp: boolean
) {
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
}

View File

@@ -1,240 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn(),
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as {
initialMode?: string | null
changeTracker?: { checkState: () => void }
} | null
}))
const mockApp = vi.hoisted(() => ({
rootGraph: { extra: {} as Record<string, unknown> }
}))
const mockSetMode = vi.hoisted(() => vi.fn())
const mockAppModeStore = vi.hoisted(() => ({
exitBuilder: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => mockAppModeStore
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
}))
import { useAppSetDefaultView } from './useAppSetDefaultView'
describe('useAppSetDefaultView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.activeWorkflow = null
mockApp.rootGraph.extra = {}
})
describe('settingView', () => {
it('reflects dialogStore.isDialogOpen', () => {
mockDialogStore.isDialogOpen.mockReturnValue(true)
const { settingView } = useAppSetDefaultView()
expect(settingView.value).toBe(true)
})
})
describe('showDialog', () => {
it('opens dialog via dialogService', () => {
const { showDialog } = useAppSetDefaultView()
showDialog()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
})
it('passes initialOpenAsApp true when initialMode is not graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
it('passes initialOpenAsApp false when initialMode is graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(false)
})
it('passes initialOpenAsApp true when no active workflow', () => {
mockWorkflowStore.activeWorkflow = null
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
})
describe('handleApply', () => {
it('sets initialMode to app when openAsApp is true', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
})
it('closes dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view'
})
})
it('shows confirmation dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.key).toBe('builder-default-view-applied')
expect(confirmCall.props.appliedAsApp).toBe(true)
})
it('passes appliedAsApp false to confirmation dialog when graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.props.appliedAsApp).toBe(false)
})
})
describe('applied dialog', () => {
function applyAndGetConfirmDialog(openAsApp: boolean) {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
applyCall.props.onApply(openAsApp)
return mockDialogService.showLayoutDialog.mock.calls[1][0]
}
it('onViewApp sets mode to app and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onViewApp()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onExitToWorkflow exits builder and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onExitToWorkflow()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
mockDialogStore.closeDialog.mockClear()
confirmCall.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
})
})
})

View File

@@ -1,82 +0,0 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
import { useAppModeStore } from '@/stores/appModeStore'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
function showDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DefaultViewDialogContent,
props: {
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
onApply: handleApply,
onClose: closeDialog
}
})
}
function handleApply(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}
function showAppliedDialog(appliedAsApp: boolean) {
dialogService.showLayoutDialog({
key: APPLIED_DIALOG_KEY,
component: BuilderDefaultModeAppliedDialogContent,
props: {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {
closeAppliedDialog()
appModeStore.exitBuilder()
},
onClose: closeAppliedDialog
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function closeAppliedDialog() {
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
}
return { settingView, showDialog }
}

View File

@@ -0,0 +1,337 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useBuilderSave } from './useBuilderSave'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
const mockSaveWorkflowAs = vi.hoisted(() =>
vi.fn<() => Promise<boolean | null>>()
)
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockSetWorkflowDefaultView = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockActiveWorkflow = ref<{
filename: string
initialMode?: string | null
} | null>(null)
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({ toastErrorHandler: mockToastErrorHandler })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEnterLinear: mockTrackEnterLinear })
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
saveWorkflow: mockSaveWorkflow,
saveWorkflowAs: mockSaveWorkflowAs
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: mockCloseDialog })
}))
vi.mock('./builderViewOptions', () => ({
setWorkflowDefaultView: mockSetWorkflowDefaultView
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
showConfirmDialog: mockShowConfirmDialog
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) => {
if (params) return `${key}:${JSON.stringify(params)}`
return key
}
}))
vi.mock('./BuilderSaveDialogContent.vue', () => ({
default: { template: '<div />' }
}))
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
describe('useBuilderSave', () => {
beforeEach(() => {
vi.clearAllMocks()
mockActiveWorkflow.value = null
})
describe('save()', () => {
it('does nothing when there is no active workflow', async () => {
const { save } = useBuilderSave()
await save()
expect(mockSaveWorkflow).not.toHaveBeenCalled()
})
it('saves workflow directly without showing a dialog', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
mockSaveWorkflow.mockResolvedValueOnce(undefined)
const { save } = useBuilderSave()
await save()
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
})
it('toasts error on failure', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const error = new Error('save failed')
mockSaveWorkflow.mockRejectedValueOnce(error)
const { save } = useBuilderSave()
await save()
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
})
it('prevents concurrent saves', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
let resolveSave!: () => void
mockSaveWorkflow.mockReturnValueOnce(
new Promise<void>((r) => {
resolveSave = r
})
)
const { save, isSaving } = useBuilderSave()
const firstSave = save()
expect(isSaving.value).toBe(true)
await save()
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
resolveSave()
await firstSave
expect(isSaving.value).toBe(false)
})
})
describe('saveAs()', () => {
it('does nothing when there is no active workflow', () => {
mockActiveWorkflow.value = null
const { saveAs } = useBuilderSave()
saveAs()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
})
it('opens save dialog with correct defaultFilename and defaultOpenAsApp', () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { saveAs } = useBuilderSave()
saveAs()
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
expect(key).toBe(SAVE_DIALOG_KEY)
expect(props.defaultFilename).toBe('my-workflow')
expect(props.defaultOpenAsApp).toBe(true)
})
it('passes defaultOpenAsApp: false when initialMode is graph', () => {
mockActiveWorkflow.value = {
filename: 'my-workflow',
initialMode: 'graph'
}
const { saveAs } = useBuilderSave()
saveAs()
const { props } = mockShowLayoutDialog.mock.calls[0][0]
expect(props.defaultOpenAsApp).toBe(false)
})
})
describe('save dialog callbacks', () => {
function getSaveDialogProps() {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { saveAs } = useBuilderSave()
saveAs()
return mockShowLayoutDialog.mock.calls[0][0].props as {
onSave: (filename: string, openAsApp: boolean) => Promise<void>
onClose: () => void
}
}
it('onSave calls saveWorkflowAs then setWorkflowDefaultView on success', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
mockActiveWorkflow.value,
{
filename: 'new-name'
}
)
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(
mockActiveWorkflow.value,
true
)
})
it('onSave uses fresh activeWorkflow reference for setWorkflowDefaultView', async () => {
const newWorkflow = { filename: 'new-name', initialMode: 'app' }
mockSaveWorkflowAs.mockImplementationOnce(async () => {
mockActiveWorkflow.value = newWorkflow
return true
})
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(newWorkflow, true)
})
it('onSave does not mutate or close when saveWorkflowAs returns falsy', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(null)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
expect(mockSetWorkflowDefaultView).not.toHaveBeenCalled()
expect(mockCloseDialog).not.toHaveBeenCalled()
})
it('onSave closes dialog and shows success dialog after successful save', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
})
it('shows app success message when openAsApp is true', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.props.promptText).toBe('builderSave.successBodyApp')
})
it('shows graph success message with exit builder button when openAsApp is false', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.props.promptText).toBe('builderSave.successBodyGraph')
expect(successCall.footerProps.confirmText).toBe(
'linearMode.builder.exit'
)
expect(successCall.footerProps.cancelText).toBe('builderToolbar.viewApp')
})
it('onSave toasts error and closes dialog on failure', async () => {
const error = new Error('save-as failed')
mockSaveWorkflowAs.mockRejectedValueOnce(error)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
})
it('prevents concurrent handleSaveAs calls', async () => {
let resolveSaveAs!: (v: boolean) => void
mockSaveWorkflowAs.mockReturnValueOnce(
new Promise<boolean>((r) => {
resolveSaveAs = r
})
)
const { onSave } = getSaveDialogProps()
const firstSave = onSave('new-name', true)
await onSave('other-name', true)
expect(mockSaveWorkflowAs).toHaveBeenCalledOnce()
resolveSaveAs(true)
await firstSave
})
})
describe('graph success dialog callbacks', () => {
async function getGraphSuccessDialogProps() {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { saveAs } = useBuilderSave()
saveAs()
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
onSave: (filename: string, openAsApp: boolean) => Promise<void>
}
await onSave('new-name', false)
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
onConfirm: () => void
onCancel: () => void
}
}
it('onConfirm closes dialog and exits builder', async () => {
const { onConfirm } = await getGraphSuccessDialogProps()
onConfirm()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
it('onCancel closes dialog and switches to app mode', async () => {
const { onCancel } = await getGraphSuccessDialogProps()
onCancel()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
source: 'app_builder'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
})
})

View File

@@ -0,0 +1,135 @@
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ref } from 'vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
const isSaving = ref(false)
export function useBuilderSave() {
const { toastErrorHandler } = useErrorHandling()
const { setMode } = useAppMode()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
function closeDialog(key: string) {
dialogStore.closeDialog({ key })
}
async function save() {
if (isSaving.value) return
const workflow = workflowStore.activeWorkflow
if (!workflow) return
isSaving.value = true
try {
await workflowService.saveWorkflow(workflow)
} catch (e) {
toastErrorHandler(e)
} finally {
isSaving.value = false
}
}
function saveAs() {
if (isSaving.value) return
const workflow = workflowStore.activeWorkflow
if (!workflow) return
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename: workflow.filename,
defaultOpenAsApp: workflow.initialMode !== 'graph',
onSave: handleSaveAs,
onClose: () => closeDialog(SAVE_DIALOG_KEY)
}
})
}
async function handleSaveAs(filename: string, openAsApp: boolean) {
if (isSaving.value) return
isSaving.value = true
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
const saved = await workflowService.saveWorkflowAs(workflow, {
filename
})
if (!saved) return
const activeWorkflow = workflowStore.activeWorkflow
if (!activeWorkflow) return
setWorkflowDefaultView(activeWorkflow, openAsApp)
closeDialog(SAVE_DIALOG_KEY)
showSuccessDialog(openAsApp ? 'app' : 'graph')
} catch (e) {
toastErrorHandler(e)
closeDialog(SAVE_DIALOG_KEY)
} finally {
isSaving.value = false
}
}
function showSuccessDialog(viewType: 'app' | 'graph') {
const promptText =
viewType === 'app'
? t('builderSave.successBodyApp')
: t('builderSave.successBodyGraph')
showConfirmDialog({
key: SUCCESS_DIALOG_KEY,
headerProps: {
title: t('builderSave.successTitle'),
icon: 'icon-[lucide--circle-check-big] text-green-500'
},
props: { promptText, preserveNewlines: true },
footerProps:
viewType === 'graph'
? {
cancelText: t('builderToolbar.viewApp'),
confirmText: t('linearMode.builder.exit'),
confirmVariant: 'secondary' as const,
onCancel: () => {
closeDialog(SUCCESS_DIALOG_KEY)
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
appModeStore.exitBuilder()
}
}
: {
cancelText: t('g.close'),
confirmText: t('builderToolbar.viewApp'),
confirmVariant: 'secondary' as const,
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
}
}
})
}
return { save, saveAs, isSaving }
}

View File

@@ -4,13 +4,10 @@ import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'builder:arrange',
'setDefaultView'
'builder:arrange'
] as const
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
@@ -19,10 +16,8 @@ const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const { settingView, showDialog } = useAppSetDefaultView()
const activeStep = computed<BuilderStepId>(() => {
if (settingView.value) return 'setDefaultView'
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
@@ -47,23 +42,14 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
showDialog()
} else {
setMode(stepId)
}
}
function goBack() {
if (isFirstStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
}
return {
@@ -72,7 +58,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
navigateToStep: setMode,
goBack,
goNext
}

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Badge from './Badge.vue'
const meta = {
title: 'Components/Badges/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'NEW',
severity: 'default'
}
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Secondary: Story = {
args: {
label: 'NEW',
severity: 'secondary'
}
}
export const Warn: Story = {
args: {
label: 'NEW',
severity: 'warn'
}
}
export const Danger: Story = {
args: {
label: 'NEW',
severity: 'danger'
}
}
export const Contrast: Story = {
args: {
label: 'NEW',
severity: 'contrast'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeveritiesLabel: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge label="NEW" severity="default" />
<Badge label="NEW" severity="secondary" />
<Badge label="NEW" severity="warn" />
<Badge label="NEW" severity="danger" />
<Badge label="NEW" severity="contrast" />
</div>
`
})
}
export const AllSeveritiesDot: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge variant="dot" severity="default" />
<Badge variant="dot" severity="secondary" />
<Badge variant="dot" severity="warn" />
<Badge variant="dot" severity="danger" />
<Badge variant="dot" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<Badge label="NEW" variant="label" />
<span class="text-xs text-muted-foreground">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge variant="dot" severity="danger" />
<span class="text-xs text-muted-foreground">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge label="5" variant="circle" />
<span class="text-xs text-muted-foreground">circle</span>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
})
it('renders numeric label', () => {
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
})
it('defaults to dot variant when no label is provided', () => {
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
})
it('defaults to label variant when label is provided', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
})
it('applies circle variant', () => {
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
})
it('merges custom class via cn()', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
it.each([
['default', 'text-white'],
['secondary', 'text-white'],
['warn', 'text-white'],
['danger', 'text-white'],
['contrast', 'text-base-background']
] as const)(
'%s severity retains its text color class',
(severity, expectedColor) => {
const classes = badgeVariants({ severity, variant: 'label' })
expect(classes).toContain(expectedColor)
expect(classes).toContain('text-3xs')
}
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
})
})
})

View File

@@ -3,8 +3,8 @@ import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
import { badgeVariants } from './badge.variants'
import type { BadgeVariants } from './badge.variants'
const {
label,
@@ -13,14 +13,14 @@ const {
class: className
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
severity?: BadgeVariants['severity']
variant?: BadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
statusBadgeVariants({
badgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),

View File

@@ -3,7 +3,7 @@
data-testid="badge-pill"
:class="
cn(
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-2xs',
textColorClass
)
"

View File

@@ -59,7 +59,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-2xs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -1,17 +1,28 @@
<template>
<Chip removable @remove="emit('remove', $event)">
<Badge size="small" :class="semanticBadgeClass">
{{ badge }}
</Badge>
<span
class="inline-flex items-center gap-1 rounded-2xl bg-surface-700 py-0.5 pr-1 pl-2 text-xs"
>
<Badge :label="badge" :class="semanticBadgeClass" />
{{ text }}
</Chip>
<button
type="button"
:aria-label="$t('g.remove')"
class="inline-flex cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-surface-600"
@click="emit('remove', $event)"
>
<i
class="icon-[lucide--x] size-3 text-muted-foreground"
aria-hidden="true"
/>
</button>
</span>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import Chip from 'primevue/chip'
import { computed } from 'vue'
import Badge from '@/components/common/Badge.vue'
export interface SearchFilter {
text: string
badge: string

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render, screen } from '@testing-library/vue'
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

View File

@@ -23,9 +23,10 @@
</span>
<Badge
v-if="showNodeBadgeText"
:value="nodeBadgeText"
:label="nodeBadgeText"
severity="secondary"
class="leaf-count-badge"
:variant="nodeBadgeText.length > 1 ? 'label' : 'circle'"
class="ml-2"
/>
</div>
<div
@@ -38,7 +39,7 @@
<script setup lang="ts" generic="T">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import { computed, inject, ref } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
@@ -146,9 +147,6 @@ if (props.node.droppable) {
align-items: center;
justify-content: space-between;
}
.leaf-count-badge {
margin-left: 0.5rem;
}
.node-content {
display: flex;
align-items: center;

View File

@@ -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
}))

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

@@ -44,7 +44,7 @@ const {
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.badge"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
>
{{ item.badge }}
</span>

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-white',
secondary: 'bg-secondary-background-hover text-white',
warn: 'bg-warning-background text-white',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,26 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const statusBadgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-base-foreground',
secondary: 'bg-secondary-background text-base-foreground',
warn: 'bg-warning-background text-base-background',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>

View File

@@ -2,11 +2,15 @@
<div
class="flex items-center gap-2 p-4 font-inter text-sm font-bold text-base-foreground"
>
<i v-if="icon" :class="cn(icon, 'size-4')" aria-hidden="true" />
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
defineProps<{
title?: string
icon?: string
}>()
</script>

View File

@@ -5,6 +5,7 @@ import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
interface ConfirmDialogOptions {
key?: string
headerProps?: ComponentAttrs<typeof ConfirmHeader>
props?: ComponentAttrs<typeof ConfirmBody>
footerProps?: ComponentAttrs<typeof ConfirmFooter>
@@ -12,8 +13,9 @@ interface ConfirmDialogOptions {
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { headerProps, props, footerProps } = options
const { key, headerProps, props, footerProps } = options
return dialogStore.showDialog({
key,
headerComponent: ConfirmHeader,
component: ConfirmBody,
footerComponent: ConfirmFooter,

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

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import Column from 'primevue/column'
import PrimeVue from 'primevue/config'
@@ -125,13 +125,13 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
return 'default'
case EventType.ACCOUNT_CREATED:
return 'info'
return 'secondary'
case EventType.API_USAGE_COMPLETED:
return 'warning'
return 'warn'
default:
return 'info'
return 'secondary'
}
})
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {

View File

@@ -20,7 +20,7 @@
<Column field="event_type" :header="$t('credits.eventType')">
<template #body="{ data }">
<Badge
:value="customerEventService.formatEventType(data.event_type)"
:label="customerEventService.formatEventType(data.event_type)"
:severity="customerEventService.getEventSeverity(data.event_type)"
/>
</template>
@@ -91,7 +91,7 @@
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'

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

@@ -1,22 +1,20 @@
import { Form } from '@primevue/forms'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
@@ -35,17 +33,17 @@ 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(() => ({
const mockLoadingRef = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
return mockLoadingRef.value
}
}))
}))
@@ -58,259 +56,145 @@ vi.mock('primevue/usetoast', () => ({
}))
}))
const forgotPasswordText = enMessages.auth.login.forgotPassword
const loginButtonText = enMessages.auth.login.loginButton
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
mockLoadingRef.value = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent(props: Record<string, unknown> = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
const user = userEvent.setup()
const result = render(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
components: { Form, Button, InputText, Password, ProgressSpinner }
},
props,
...options
props
})
return { ...result, user }
}
function getEmailInput() {
return screen.getByPlaceholderText(enMessages.auth.login.emailPlaceholder)
}
function getPasswordInput() {
return screen.getByPlaceholderText(
enMessages.auth.login.passwordPlaceholder
)
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
)
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
)
const { user } = renderComponent()
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const emailInput = getEmailInput()
const focusSpy = vi.spyOn(emailInput, 'focus')
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
await user.click(screen.getByText(forgotPasswordText))
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
it('emits submit event when form is submitted with valid data', async () => {
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
await user.type(getEmailInput(), 'test@example.com')
await user.type(getPasswordInput(), 'password123')
await user.click(screen.getByRole('button', { name: loginButtonText }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
it('does not emit submit event when form data is invalid', async () => {
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
await user.type(getEmailInput(), 'invalid-email')
await user.type(getPasswordInput(), 'password123')
await user.click(screen.getByRole('button', { name: loginButtonText }))
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
it('shows spinner when loading', () => {
mockLoadingRef.value = true
renderComponent()
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: loginButtonText })
).not.toBeInTheDocument()
})
it('shows button when not loading', () => {
mockLoading = false
renderComponent()
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: loginButtonText })
).toBeInTheDocument()
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
renderComponent()
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
const emailInput = getEmailInput()
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-in-email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('type', 'text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
renderComponent()
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
const passwordInput = getPasswordInput()
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-in-password')
expect(passwordInput).toHaveAttribute('name', 'password')
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
describe('Forgot Password with valid email', () => {
it('calls sendPasswordReset when email is valid', async () => {
const { user } = renderComponent()
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
await user.type(getEmailInput(), 'test@example.com')
await user.click(screen.getByText(forgotPasswordText))
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
expect(mockToastAdd).not.toHaveBeenCalled()
})
})
})

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

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { fireEvent, render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -129,6 +130,10 @@ describe('SelectionToolbox', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefMock = {
type: 'TestNode',
title: 'Test Node'
} as unknown
// Mock the canvas to avoid "getCanvas: canvas is null" errors
canvasStore.canvas = createMockCanvas()
@@ -136,8 +141,8 @@ describe('SelectionToolbox', () => {
vi.resetAllMocks()
})
const mountComponent = (props = {}) => {
return mount(SelectionToolbox, {
function renderComponent(props = {}): { container: Element } {
const { container } = render(SelectionToolbox, {
props,
global: {
plugins: [i18n, PrimeVue],
@@ -169,7 +174,9 @@ describe('SelectionToolbox', () => {
Load3DViewerButton: {
template: '<div class="load-3d-viewer-button" />'
},
MaskEditorButton: { template: '<div class="mask-editor-button" />' },
MaskEditorButton: {
template: '<div class="mask-editor-button" />'
},
DeleteButton: {
template:
'<button data-testid="delete-button" class="delete-button" />'
@@ -193,6 +200,7 @@ describe('SelectionToolbox', () => {
}
}
})
return { container }
}
describe('Button Visibility Logic', () => {
@@ -204,91 +212,91 @@ describe('SelectionToolbox', () => {
it('should show info button only for single selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(true)
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeTruthy()
// Multiple node selection
// Multiple node selection - render in separate test scope
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.info-button').exists()).toBe(false)
const { container: container2 } = renderComponent()
expect(container2.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when node definition is not found', () => {
canvasStore.selectedItems = [createMockPositionable()]
// mock nodedef and return null
nodeDefMock = null
// remount component
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(false)
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
true
)
const { container } = renderComponent()
expect(
container.querySelector('[data-testid="color-picker-button"]')
).toBeTruthy()
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
const { container: container2 } = renderComponent()
expect(
wrapper2.find('[data-testid="color-picker-button"]').exists()
).toBe(true)
container2.querySelector('[data-testid="color-picker-button"]')
).toBeTruthy()
})
it('should show frame nodes only for multiple selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
const { container } = renderComponent()
expect(container.querySelector('.frame-nodes')).toBeFalsy()
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
const { container: container2 } = renderComponent()
expect(container2.querySelector('.frame-nodes')).toBeTruthy()
})
it('should show bypass button for appropriate selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
const { container } = renderComponent()
expect(
container.querySelector('[data-testid="bypass-button"]')
).toBeTruthy()
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
const { container: container2 } = renderComponent()
expect(
container2.querySelector('[data-testid="bypass-button"]')
).toBeTruthy()
})
it('should show common buttons for all selections', () => {
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const { container } = renderComponent()
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
expect(
wrapper.find('[data-testid="convert-to-subgraph-button"]').exists()
).toBe(true)
expect(wrapper.find('[data-testid="more-options-button"]').exists()).toBe(
true
)
container.querySelector('[data-testid="delete-button"]')
).toBeTruthy()
expect(
container.querySelector('[data-testid="convert-to-subgraph-button"]')
).toBeTruthy()
expect(
container.querySelector('[data-testid="more-options-button"]')
).toBeTruthy()
})
it('should show mask editor only for single image nodes', () => {
@@ -297,15 +305,14 @@ describe('SelectionToolbox', () => {
// Single image node
isImageNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
const { container } = renderComponent()
expect(container.querySelector('.mask-editor-button')).toBeTruthy()
// Single non-image node
isImageNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
const { container: container2 } = renderComponent()
expect(container2.querySelector('.mask-editor-button')).toBeFalsy()
})
it('should show Color picker button only for single Load3D nodes', () => {
@@ -314,15 +321,14 @@ describe('SelectionToolbox', () => {
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
const { container } = renderComponent()
expect(container.querySelector('.load-3d-viewer-button')).toBeTruthy()
// Single non-Load3D node
isLoad3dNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
const { container: container2 } = renderComponent()
expect(container2.querySelector('.load-3d-viewer-button')).toBeFalsy()
})
it('should show ExecuteButton only when output nodes are selected', () => {
@@ -335,22 +341,20 @@ describe('SelectionToolbox', () => {
{ type: 'SaveImage' }
] as LGraphNode[])
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.execute-button').exists()).toBe(true)
const { container } = renderComponent()
expect(container.querySelector('.execute-button')).toBeTruthy()
// Without output node selected
isOutputNodeSpy.mockReturnValue(false)
filterOutputNodesSpy.mockReturnValue([])
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.execute-button').exists()).toBe(false)
const { container: container2 } = renderComponent()
expect(container2.querySelector('.execute-button')).toBeFalsy()
// No selection at all
canvasStore.selectedItems = []
wrapper2.unmount()
const wrapper3 = mountComponent()
expect(wrapper3.find('.execute-button').exists()).toBe(false)
const { container: container3 } = renderComponent()
expect(container3.querySelector('.execute-button')).toBeFalsy()
})
})
@@ -358,19 +362,20 @@ describe('SelectionToolbox', () => {
it('should show dividers between button groups when both groups have buttons', () => {
// Setup single node to show info + other buttons
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const { container } = renderComponent()
const dividers = wrapper.findAll('.vertical-divider')
const dividers = container.querySelectorAll('.vertical-divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should not show dividers when adjacent groups are empty', () => {
// No selection should show minimal buttons and dividers
canvasStore.selectedItems = []
const wrapper = mountComponent()
const { container } = renderComponent()
const buttons = wrapper.find('.panel').element.children
expect(buttons.length).toBeGreaterThan(0) // At least MoreOptions should show
expect(
container.querySelector('[data-testid="more-options-button"]')
).toBeTruthy()
})
})
@@ -390,9 +395,9 @@ describe('SelectionToolbox', () => {
} as ReturnType<typeof useExtensionService>)
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const { container } = renderComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
expect(container.querySelector('.extension-command-button')).toBeTruthy()
})
it('should not render extension commands when none available', () => {
@@ -400,47 +405,9 @@ describe('SelectionToolbox', () => {
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const { container } = renderComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
})
})
describe('Container Styling', () => {
it('should apply minimap container styles', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.classes()).toContain('selection-toolbox')
expect(panel.classes()).toContain('absolute')
expect(panel.classes()).toContain('left-1/2')
expect(panel.classes()).toContain('rounded-lg')
})
it('should handle animation class conditionally', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.exists()).toBe(true)
expect(container.querySelector('.extension-command-button')).toBeFalsy()
})
})
@@ -461,10 +428,11 @@ describe('SelectionToolbox', () => {
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const { container } = renderComponent()
const panel = wrapper.find('.panel')
await panel.trigger('wheel')
const panel = container.querySelector('.panel')
expect(panel).toBeTruthy()
await fireEvent.wheel(panel!)
expect(forwardEventToCanvasSpy).toHaveBeenCalled()
})
@@ -478,12 +446,12 @@ describe('SelectionToolbox', () => {
it('should hide most buttons when no items selected', () => {
canvasStore.selectedItems = []
const wrapper = mountComponent()
const { container } = renderComponent()
expect(wrapper.find('.info-button').exists()).toBe(false)
expect(wrapper.find('.color-picker-button').exists()).toBe(false)
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
expect(wrapper.find('.bookmark-button').exists()).toBe(false)
expect(container.querySelector('.info-button')).toBeFalsy()
expect(container.querySelector('.color-picker-button')).toBeFalsy()
expect(container.querySelector('.frame-nodes')).toBeFalsy()
expect(container.querySelector('.bookmark-button')).toBeFalsy()
})
})
})

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>
@@ -49,14 +49,14 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('nodeHelpPage.inputs') }}
</h4>
<div
v-for="input in inputs"
:key="input.name"
class="flex items-center justify-between gap-2 text-xxs"
class="flex items-center justify-between gap-2 text-2xs"
>
<span class="text-foreground shrink-0">{{ input.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
@@ -71,14 +71,14 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('nodeHelpPage.outputs') }}
</h4>
<div
v-for="output in outputs"
:key="output.name"
class="flex items-center justify-between gap-2 text-xxs"
class="flex items-center justify-between gap-2 text-2xs"
>
<span class="text-foreground shrink-0">{{ output.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{

View File

@@ -1,4 +1,6 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -25,61 +27,79 @@ const JobFiltersBarStub = {
template: '<div />'
}
const JobAssetsListStub = {
name: 'JobAssetsList',
template: '<div class="job-assets-list-stub" />'
const testJob: JobListItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'pending'
}
const JobAssetsListStub = defineComponent({
name: 'JobAssetsList',
setup(_, { emit }) {
return {
triggerCancel: () => emit('cancel-item', testJob),
triggerDelete: () => emit('delete-item', testJob),
triggerView: () => emit('view-item', testJob)
}
},
template: `
<div class="job-assets-list-stub">
<button data-testid="stub-cancel" @click="triggerCancel()" />
<button data-testid="stub-delete" @click="triggerDelete()" />
<button data-testid="stub-view" @click="triggerView()" />
</div>
`
})
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'pending'
})
const defaultProps = {
headerTitle: 'Jobs',
queuedCount: 1,
selectedJobTab: 'All' as const,
selectedWorkflowFilter: 'all' as const,
selectedSortMode: 'mostRecent' as const,
displayedJobGroups: [],
hasFailedJobs: false
}
const mountComponent = () =>
mount(QueueOverlayExpanded, {
props: {
headerTitle: 'Jobs',
queuedCount: 1,
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
displayedJobGroups: [],
hasFailedJobs: false
},
global: {
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
}
}
})
const stubs = {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
}
describe('QueueOverlayExpanded', () => {
it('renders JobAssetsList', () => {
const wrapper = mountComponent()
expect(wrapper.find('.job-assets-list-stub').exists()).toBe(true)
const { container } = render(QueueOverlayExpanded, {
props: defaultProps,
global: { stubs }
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.job-assets-list-stub')).toBeTruthy()
})
it('re-emits list item actions from JobAssetsList', async () => {
const wrapper = mountComponent()
const job = createJob()
const jobAssetsList = wrapper.findComponent({ name: 'JobAssetsList' })
const user = userEvent.setup()
const onCancelItem = vi.fn<(item: JobListItem) => void>()
const onDeleteItem = vi.fn<(item: JobListItem) => void>()
const onViewItem = vi.fn<(item: JobListItem) => void>()
jobAssetsList.vm.$emit('cancel-item', job)
jobAssetsList.vm.$emit('delete-item', job)
jobAssetsList.vm.$emit('view-item', job)
await wrapper.vm.$nextTick()
render(QueueOverlayExpanded, {
props: { ...defaultProps, onCancelItem, onDeleteItem, onViewItem },
global: { stubs }
})
expect(wrapper.emitted('cancelItem')?.[0]).toEqual([job])
expect(wrapper.emitted('deleteItem')?.[0]).toEqual([job])
expect(wrapper.emitted('viewItem')?.[0]).toEqual([job])
await user.click(screen.getByTestId('stub-cancel'))
await user.click(screen.getByTestId('stub-delete'))
await user.click(screen.getByTestId('stub-view'))
expect(onCancelItem).toHaveBeenCalledWith(testJob)
expect(onDeleteItem).toHaveBeenCalledWith(testJob)
expect(onViewItem).toHaveBeenCalledWith(testJob)
})
})

View File

@@ -1,4 +1,8 @@
import { mount } from '@vue/test-utils'
/* eslint-disable vue/one-component-per-file -- test stubs */
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
@@ -14,7 +18,36 @@ const JobDetailsPopoverStub = defineComponent({
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-details-popover-stub" />'
template:
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
})
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
props: {
previewUrl: { type: String, default: undefined },
isVideoPreview: { type: Boolean, default: false },
previewAlt: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconClass: { type: String, default: undefined },
primaryText: { type: String, default: undefined },
secondaryText: { type: String, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined }
},
setup(_, { emit }) {
return { emitPreviewClick: () => emit('preview-click') }
},
template: `
<div class="assets-list-item-stub"
:data-preview-url="previewUrl"
:data-is-video="isVideoPreview">
<span>{{ primaryText }}</span>
<button data-testid="preview-trigger" @click="emitPreviewClick" />
<i v-if="iconName && !previewUrl" :class="iconName" @click="emitPreviewClick" />
<slot name="actions" />
</div>
`
})
vi.mock('vue-i18n', () => {
@@ -72,7 +105,12 @@ const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
...overrides
})
const mountJobAssetsList = (jobs: JobListItem[]) => {
function renderJobAssetsList(
jobs: JobListItem[],
callbacks: {
onViewItem?: (item: JobListItem) => void
} = {}
) {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
@@ -81,15 +119,23 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
}
]
return mount(JobAssetsList, {
props: { displayedJobGroups },
const user = userEvent.setup()
const result = render(JobAssetsList, {
props: {
displayedJobGroups,
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
},
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
JobDetailsPopover: JobDetailsPopoverStub,
AssetsListItem: AssetsListItemStub
}
}
})
return { ...result, user }
}
function createDomRect({
@@ -124,24 +170,23 @@ afterEach(() => {
describe('JobAssetsList', () => {
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const onViewItem = vi.fn()
const { user } = renderJobAssetsList([job], { onViewItem })
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
listItem.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
await user.click(screen.getByTestId('preview-trigger'))
expect(wrapper.emitted('viewItem')).toEqual([[job]])
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
expect(wrapper.emitted('viewItem')).toEqual([[job]])
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
@@ -149,16 +194,18 @@ describe('JobAssetsList', () => {
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
expect(listItem.props('isVideoPreview')).toBe(true)
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(
'/api/view/job-1.webm'
)
expect(stubRoot.getAttribute('data-is-video')).toBe('true')
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
await user.dblClick(stubRoot)
expect(wrapper.emitted('viewItem')).toEqual([[job]])
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
@@ -166,14 +213,13 @@ describe('JobAssetsList', () => {
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
await listItem.find('i').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
@@ -181,13 +227,13 @@ describe('JobAssetsList', () => {
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const wrapper = mountJobAssetsList([job])
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
expect(wrapper.emitted('viewItem')).toBeUndefined()
expect(onViewItem).not.toHaveBeenCalled()
})
it('emits viewItem from the View button for completed jobs without preview output', async () => {
@@ -195,92 +241,90 @@ describe('JobAssetsList', () => {
iconImageUrl: undefined,
taskRef: createTaskRef()
})
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const onViewItem = vi.fn()
const { container } = renderJobAssetsList([job], { onViewItem })
await jobRow.trigger('mouseenter')
const viewButton = wrapper
.findAll('button')
.find((button) => button.text() === 'menuLabels.View')
expect(viewButton).toBeDefined()
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await viewButton!.trigger('click')
await fireEvent.click(screen.getByText('menuLabels.View'))
await nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('shows and hides the job details popover with hover delays', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const { container } = renderJobAssetsList([job])
await jobRow.trigger('mouseenter')
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(199)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: job.id,
workflowId: 'workflow-1'
})
const popoverStub = container.querySelector('.job-details-popover-stub')!
expect(popoverStub).not.toBeNull()
expect(popoverStub.getAttribute('data-job-id')).toBe(job.id)
expect(popoverStub.getAttribute('data-workflow-id')).toBe('workflow-1')
await jobRow.trigger('mouseleave')
await fireEvent.mouseLeave(jobRow)
await vi.advanceTimersByTimeAsync(149)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('keeps the job details popover open while hovering the popover', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const { container } = renderJobAssetsList([job])
await jobRow.trigger('mouseenter')
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
await jobRow.trigger('mouseleave')
await fireEvent.mouseLeave(jobRow)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const popover = wrapper.find('.job-details-popover')
expect(popover.exists()).toBe(true)
const popoverWrapper = container.querySelector('.job-details-popover')!
expect(popoverWrapper).not.toBeNull()
await popover.trigger('mouseenter')
await fireEvent.mouseEnter(popoverWrapper)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
await popover.trigger('mouseleave')
await fireEvent.mouseLeave(popoverWrapper)
await vi.advanceTimersByTimeAsync(149)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 40,
@@ -289,22 +333,23 @@ describe('JobAssetsList', () => {
})
)
await jobRow.trigger('mouseenter')
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.find('.job-details-popover')
expect(popover.attributes('style')).toContain('left: 248px;')
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('style')).toContain('left: 248px;')
})
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 980,
@@ -313,83 +358,89 @@ describe('JobAssetsList', () => {
})
)
await jobRow.trigger('mouseenter')
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.find('.job-details-popover')
expect(popover.attributes('style')).toContain('left: 672px;')
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('style')).toContain('left: 672px;')
})
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const wrapper = mountJobAssetsList([firstJob, secondJob])
const firstRow = wrapper.find('[data-job-id="job-1"]')
const secondRow = wrapper.find('[data-job-id="job-2"]')
const { container } = renderJobAssetsList([firstJob, secondJob])
await firstRow.trigger('mouseenter')
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
await fireEvent.mouseEnter(firstRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
'job-1'
)
const popoverJobId = container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
expect(popoverJobId).toBe('job-1')
await firstRow.trigger('mouseleave')
await secondRow.trigger('mouseenter')
await fireEvent.mouseLeave(firstRow)
await fireEvent.mouseEnter(secondRow)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
await secondRow.trigger('mouseleave')
await fireEvent.mouseLeave(secondRow)
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const wrapper = mountJobAssetsList([firstJob, secondJob])
const firstRow = wrapper.find('[data-job-id="job-1"]')
const secondRow = wrapper.find('[data-job-id="job-2"]')
const { container } = renderJobAssetsList([firstJob, secondJob])
await firstRow.trigger('mouseenter')
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
await fireEvent.mouseEnter(firstRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
'job-1'
)
const firstPopoverJobId = container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
expect(firstPopoverJobId).toBe('job-1')
await firstRow.trigger('mouseleave')
await secondRow.trigger('mouseenter')
await fireEvent.mouseLeave(firstRow)
await fireEvent.mouseEnter(secondRow)
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
await vi.advanceTimersByTimeAsync(50)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props('jobId')).toBe('job-2')
const popoverStub = container.querySelector('.job-details-popover-stub')!
expect(popoverStub).not.toBeNull()
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
})
it('does not show details if the hovered row disappears before the show delay ends', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const { container, rerender } = renderJobAssetsList([job])
await jobRow.trigger('mouseenter')
await wrapper.setProps({ displayedJobGroups: [] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await rerender({ displayedJobGroups: [] })
await nextTick()
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(wrapper.find('.job-details-popover').exists()).toBe(false)
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(container.querySelector('.job-details-popover')).toBeNull()
})
})

View File

@@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -10,29 +10,12 @@ import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const mockStoreRefs = vi.hoisted(() => ({
visible: { value: false },
newSearchBoxEnabled: { value: true }
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('pinia', async () => {
const actual = await vi.importActual('pinia')
return {
...(actual as Record<string, unknown>),
storeToRefs: () => mockStoreRefs
}
})
vi.mock('@/stores/workspace/searchBoxStore', () => ({
useSearchBoxStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
getCanvasCenter: vi.fn(() => [0, 0]),
@@ -68,13 +51,9 @@ vi.mock('@/stores/nodeDefStore', () => ({
})
}))
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
template: '<div class="node-search-box" />'
})
type EmitAddFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => void
function createFilter(
id: string,
@@ -93,15 +72,33 @@ describe('NodeSearchBoxPopover', () => {
messages: { en: {} }
})
beforeEach(() => {
setActivePinia(createPinia())
mockStoreRefs.visible.value = false
})
function renderComponent() {
let emitAddFilter: EmitAddFilter | null = null
const mountComponent = () => {
return mount(NodeSearchBoxPopover, {
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
emits: ['addFilter'],
setup(props, { emit }) {
emitAddFilter = (filter) => emit('addFilter', filter)
const filterCount = computed(() => props.filters.length)
return { filterCount }
},
template: '<output aria-label="filter count">{{ filterCount }}</output>'
})
const pinia = createTestingPinia({
stubActions: false,
initialState: {
searchBox: { visible: false }
}
})
const result = render(NodeSearchBoxPopover, {
global: {
plugins: [i18n, PrimeVue],
plugins: [i18n, PrimeVue, pinia],
stubs: {
NodeSearchBox: NodeSearchBoxStub,
Dialog: {
@@ -111,63 +108,53 @@ describe('NodeSearchBoxPopover', () => {
}
}
})
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter }
}
describe('addFilter duplicate prevention', () => {
it('should add a filter when no duplicates exist', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
const { emitAddFilter } = renderComponent()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
const filters = searchBox.props('filters') as FuseFilterWithValue<
ComfyNodeDefImpl,
string
>[]
expect(filters).toHaveLength(1)
expect(filters[0]).toEqual(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'outputType' }),
value: 'IMAGE'
})
)
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
})
it('should not add a duplicate filter with same id and value', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
const { emitAddFilter } = renderComponent()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
expect(searchBox.props('filters')).toHaveLength(1)
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
})
it('should allow filters with same id but different values', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
const { emitAddFilter } = renderComponent()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
await wrapper.vm.$nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('outputType', 'MASK'))
await nextTick()
expect(searchBox.props('filters')).toHaveLength(2)
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
})
it('should allow filters with different ids but same value', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
const { emitAddFilter } = renderComponent()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
await wrapper.vm.$nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('inputType', 'IMAGE'))
await nextTick()
expect(searchBox.props('filters')).toHaveLength(2)
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
})
})
})

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

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

@@ -8,7 +8,7 @@
>
<template #alt-title>
<span
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
>
{{ $t('g.beta') }}
</span>

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,71 +1,51 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
error: boolean
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
describe('BaseThumbnail', () => {
const mountThumbnail = (props = {}, slots = {}) => {
return mount(BaseThumbnail, {
props,
function renderThumbnail(
props: Partial<ComponentProps<typeof BaseThumbnail>> = {}
) {
return render(BaseThumbnail, {
props: props as ComponentProps<typeof BaseThumbnail>,
slots: {
default: '<img src="/test.jpg" alt="test" />',
...slots
default: '<img src="/test.jpg" alt="test" />'
}
})
}
it('renders slot content', () => {
const wrapper = mountThumbnail()
expect(wrapper.find('img').exists()).toBe(true)
renderThumbnail()
expect(screen.getByAltText('test')).toBeTruthy()
})
it('applies hover zoom with correct style', () => {
const wrapper = mountThumbnail({ isHovered: true })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toContain('transform')
expect(contentDiv.attributes('style')).toContain('scale')
renderThumbnail({ isHovered: true })
const contentDiv = screen.getByTestId('thumbnail-content')
expect(contentDiv).toHaveStyle({ transform: 'scale(1.04)' })
})
it('applies custom hover zoom value', () => {
const wrapper = mountThumbnail({ hoverZoom: 10, isHovered: true })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toContain('scale(1.1)')
renderThumbnail({ hoverZoom: 10, isHovered: true })
const contentDiv = screen.getByTestId('thumbnail-content')
expect(contentDiv).toHaveStyle({ transform: 'scale(1.1)' })
})
it('does not apply scale when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toBeUndefined()
renderThumbnail({ isHovered: false })
const contentDiv = screen.getByTestId('thumbnail-content')
expect(contentDiv).not.toHaveAttribute('style')
})
it('shows error state when image fails to load', async () => {
const wrapper = mountThumbnail()
const vm = wrapper.vm as ComponentInstance
// Manually set error since useEventListener is mocked
vm.error = true
await nextTick()
expect(
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
).toBe(true)
})
it('applies transition classes to content', () => {
const wrapper = mountThumbnail()
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.classes()).toContain('transform-gpu')
expect(contentDiv.classes()).toContain('transition-transform')
expect(contentDiv.classes()).toContain('duration-1000')
expect(contentDiv.classes()).toContain('ease-out')
renderThumbnail()
const img = screen.getByAltText('test')
await fireEvent.error(img)
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'/assets/images/default-template.png'
)
})
})

View File

@@ -5,6 +5,7 @@
<div
v-if="!error"
ref="contentRef"
data-testid="thumbnail-content"
class="size-full transform-gpu transition-transform duration-1000 ease-out"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
@@ -40,11 +40,11 @@ const isPending = computed(() => job.status === 'created')
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
<Badge :label="t('progressToast.failed')" severity="danger" />
</template>
<template v-else-if="isCompleted">
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
<Badge :label="t('progressToast.finished')" severity="contrast" />
</template>
<template v-else-if="isRunning">

View File

@@ -1,8 +1,7 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/Button.vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,7 +37,7 @@ vi.mock('firebase/auth', () => ({
// Mock pinia
vi.mock('pinia', () => ({
storeToRefs: vi.fn((store) => store)
storeToRefs: vi.fn((store: Record<string, unknown>) => store)
}))
// Mock the useFeatureFlags composable
@@ -91,13 +90,25 @@ vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
default: {
// eslint-disable-next-line vue/one-component-per-file
default: defineComponent({
name: 'CurrentUserPopoverLegacyMock',
render() {
return h('div', 'Popover Content')
},
emits: ['close']
}
emits: ['close'],
setup(_, { emit }) {
return () =>
h('div', [
'Popover Content',
h(
'button',
{
'data-testid': 'close-popover',
onClick: () => emit('close')
},
'Close'
)
])
}
})
}))
describe('CurrentUserButton', () => {
@@ -110,63 +121,66 @@ describe('CurrentUserButton', () => {
mockIsCloud.value = false
})
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
const { stubButton = true } = options ?? {}
function renderComponent() {
const user = userEvent.setup()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(CurrentUserButton, {
const result = render(CurrentUserButton, {
global: {
plugins: [i18n],
stubs: {
// Use shallow mount for popover to make testing easier
Popover: {
template: '<div><slot></slot></div>',
methods: {
toggle: vi.fn(),
hide: vi.fn()
// eslint-disable-next-line vue/one-component-per-file
Popover: defineComponent({
setup(_, { slots, expose }) {
const shown = ref(false)
expose({
toggle: () => {
shown.value = !shown.value
},
hide: () => {
shown.value = false
}
})
return () => (shown.value ? h('div', slots.default?.()) : null)
}
},
...(stubButton ? { Button: true } : {})
})
}
}
})
return { user, ...result }
}
it('renders correctly when user is logged in', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Button).exists()).toBe(true)
renderComponent()
expect(
screen.getByRole('button', { name: 'Current user' })
).toBeInTheDocument()
})
it('toggles popover on button click', async () => {
const wrapper = mountComponent()
const popoverToggleSpy = vi.fn()
const { user } = renderComponent()
// Override the ref with a mock implementation
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { toggle: popoverToggleSpy }
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
await wrapper.findComponent(Button).trigger('click')
expect(popoverToggleSpy).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Current user' }))
expect(screen.getByText('Popover Content')).toBeInTheDocument()
})
it('hides popover when closePopover is called', async () => {
const wrapper = mountComponent()
const { user } = renderComponent()
// Replace the popover.hide method with a spy
const popoverHideSpy = vi.fn()
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { hide: popoverHideSpy }
await user.click(screen.getByRole('button', { name: 'Current user' }))
expect(screen.getByText('Popover Content')).toBeInTheDocument()
// Directly call the closePopover method through the component instance
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.closePopover()
await user.click(screen.getByTestId('close-popover'))
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
})
it('shows UserAvatar in personal workspace', () => {
@@ -175,9 +189,9 @@ describe('CurrentUserButton', () => {
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('Avatar')
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
renderComponent()
expect(screen.getByText('Avatar')).toBeInTheDocument()
expect(screen.queryByText('WorkspaceProfilePic')).not.toBeInTheDocument()
})
it('shows WorkspaceProfilePic in team workspace', () => {
@@ -187,8 +201,8 @@ describe('CurrentUserButton', () => {
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('WorkspaceProfilePic')
expect(wrapper.html()).not.toContain('Avatar')
renderComponent()
expect(screen.getByText('WorkspaceProfilePic')).toBeInTheDocument()
expect(screen.queryByText('Avatar')).not.toBeInTheDocument()
})
})

View File

@@ -61,10 +61,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useFirebaseAuthActions composable
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
@@ -77,7 +77,7 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
// Mock the firebaseAuthStore with hoisted state for per-test manipulation
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
@@ -91,8 +91,8 @@ const mockAuthStoreState = vi.hoisted(() => ({
isFetchingBalance: false
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),

View File

@@ -159,7 +159,7 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -168,7 +168,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -178,8 +178,8 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {

View File

@@ -14,7 +14,7 @@
/>
<div
v-else-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -33,7 +33,7 @@
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -68,7 +68,7 @@
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -87,7 +87,7 @@
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -115,7 +115,7 @@
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}

View File

@@ -41,7 +41,7 @@ export const WithLabel: Story = {
},
template: `
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
<label class="pointer-events-none absolute left-3 top-1.5 text-2xs text-muted-foreground z-10">
Prompt
</label>
<Textarea

View File

@@ -20,7 +20,7 @@
<span ref="textRef" class="min-w-0 truncate">
<slot />
</span>
<StatusBadge
<Badge
v-if="badge !== undefined"
:label="String(badge)"
severity="contrast"
@@ -33,7 +33,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import type { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'

View File

@@ -11,8 +11,8 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { BillingPortalTargetTier } from '@/stores/authStore'
import { usdToMicros } from '@/utils/formatUtil'
/**
@@ -20,8 +20,8 @@ import { usdToMicros } from '@/utils/formatUtil'
* All actions are wrapped with error handling.
* @returns {Object} - Object containing all Firebase Auth actions
*/
export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore()
export const useAuthActions = () => {
const authStore = useAuthStore()
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()

View File

@@ -3,11 +3,11 @@ import { computed, watch } from 'vue'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { AuthUserInfo } from '@/types/authTypes'
export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()

View File

@@ -70,8 +70,8 @@ vi.mock(
})
)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
})

View File

@@ -5,7 +5,7 @@ import type {
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type {
BalanceInfo,
@@ -33,7 +33,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const firebaseAuthStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -55,12 +55,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = firebaseAuthStore.balance
const legacyBalance = authStore.balance
if (!legacyBalance) return null
return {
@@ -118,7 +118,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading.value = true
error.value = null
try {
await firebaseAuthStore.fetchBalance()
await authStore.fetchBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'

View File

@@ -56,8 +56,8 @@ vi.mock('@/scripts/api', () => ({
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
@@ -123,8 +123,8 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({}))
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({

View File

@@ -1,5 +1,5 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
@@ -78,7 +78,7 @@ export function useCoreCommands(): ComfyCommand[] {
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
@@ -996,7 +996,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Sign Out',
versionAdded: '1.18.1',
function: async () => {
await firebaseAuthActions.logout()
await authActions.logout()
}
},
{

View File

@@ -560,7 +560,7 @@ function drawDisconnectedPlaceholder(
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-xxs', '11px')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()

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