Compare commits

...

18 Commits

Author SHA1 Message Date
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
184 changed files with 6518 additions and 2409 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

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

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

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

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { badgeVariants } from './badge.variants'
import type { BadgeVariants } from './badge.variants'
const {
label,
severity = 'default',
variant,
class: className
} = defineProps<{
label?: string | number
severity?: BadgeVariants['severity']
variant?: BadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
badgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>
<template>
<span :class="badgeClass">
{{ label }}
</span>
</template>

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

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

@@ -12,9 +12,9 @@ export const statusBadgeVariants = cva({
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {

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

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

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

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

View File

@@ -347,10 +347,20 @@
"share": "مشاركة",
"workflowActions": "إجراءات سير العمل"
},
"builderFooter": {
"opensAsApp": "فتح كـ {mode}",
"opensAsGraph": "فتح كـ {mode}"
},
"builderMenu": {
"enterAppMode": "الدخول إلى وضع التطبيق",
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
},
"builderSave": {
"successBody": "هل ترغب في عرضها الآن؟",
"successBodyApp": "سيتم فتح سير العمل هذا في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.\n\nهل ترغب في عرضه الآن؟",
"successBodyGraph": "سيتم فتح سير العمل هذا كـ رسم بياني للعقد.",
"successTitle": "تم الحفظ بنجاح"
},
"builderToolbar": {
"app": "تطبيق",
"appDescription": "يفتح كتطبيق بشكل افتراضي",
@@ -359,18 +369,10 @@
"connectOutput": "توصيل مخرج",
"connectOutputBody1": "يجب توصيل مخرج واحد على الأقل قبل حفظ التطبيق.",
"connectOutputBody2": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
"defaultModeAppliedAppBody": "سيفتح سير العمل هذا في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
"defaultModeAppliedAppPrompt": "هل ترغب في عرضه الآن؟",
"defaultModeAppliedGraphBody": "سيفتح سير العمل هذا كـمخطط عقد بشكل افتراضي من الآن فصاعدًا.",
"defaultModeAppliedGraphPrompt": "هل ترغب في عرض التطبيق مع ذلك؟",
"defaultModeAppliedTitle": "تم التعيين بنجاح",
"defaultView": "تعيين العرض الافتراضي",
"defaultViewDescription": "اختر كيفية الفتح",
"defaultViewLabel": "افتراضيًا، سيتم فتح سير العمل هذا كـ:",
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
"exitToWorkflow": "الخروج إلى سير العمل",
"filename": "اسم الملف",
"inputs": "المدخلات",
"inputsDescription": "اختر المدخلات",
"label": "منشئ التطبيقات",
@@ -378,6 +380,7 @@
"nodeGraphDescription": "يفتح كرسم عقد بشكل افتراضي",
"outputs": "المخرجات",
"outputsDescription": "اختر المخرجات",
"saveAs": "حفظ باسم",
"switchToOutputs": "الانتقال إلى المخرجات",
"viewApp": "عرض التطبيق"
},
@@ -898,7 +901,15 @@
},
"errorOverlay": {
"errorCount": "{count} خطأ | {count} أخطاء",
"seeErrors": رض الأخطاء"
"missingMedia": "بعض العقد تفتقد إلى مدخلات مطلوبة",
"missingModels": "{count} نموذج مطلوب مفقود | {count} نماذج مطلوبة مفقودة",
"missingNodes": "بعض العقد مفقودة وتحتاج إلى التثبيت",
"seeErrors": "عرض الأخطاء",
"showMissingMedia": "عرض المدخلات المفقودة",
"showMissingModels": "عرض النماذج المفقودة",
"showMissingNodes": "عرض العقد المفقودة",
"showSwapNodes": "عرض العقد البديلة",
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
},
"essentials": {
"batchImage": "معالجة صور دفعة واحدة",

View File

@@ -3645,6 +3645,14 @@
"description": "تطبيق مظللات GLSL ES على الصور. المتغير u_resolution (vec2) متوفر دائماً.",
"display_name": "مظلل GLSL",
"inputs": {
"bools": {
"name": "القيم المنطقية",
"tooltip": "القيم المنطقية متوفرة كـ u_bool0-9 (bool) في كود الشادر"
},
"curves": {
"name": "المنحنيات",
"tooltip": "المنحنيات متوفرة كـ u_curve0-3 (sampler2D, 1D LUT) في كود الشادر. قم بأخذ العينة باستخدام texture(u_curve0, vec2(x, 0.5)).r"
},
"floats": {
"name": "floats",
"tooltip": "القيم العشرية متوفرة كـ u_float0-4 في كود المظلل"

View File

@@ -3682,28 +3682,31 @@
"outputsDescription": "Choose outputs",
"arrange": "Preview",
"arrangeDescription": "Review app layout",
"defaultView": "Set a default view",
"defaultViewDescription": "Choose how this opens",
"connectOutput": "Connect an output",
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
"connectOutputBody2": "Switch to the 'Output' step and click on output nodes to add them here.",
"switchToOutputs": "Switch to Outputs",
"defaultViewTitle": "Set the default view for this workflow",
"defaultViewLabel": "By default, this workflow will open as:",
"app": "App",
"appDescription": "Opens as an app by default",
"nodeGraph": "Node graph",
"nodeGraphDescription": "Opens as node graph by default",
"defaultModeAppliedTitle": "Successfully set",
"defaultModeAppliedAppBody": "This workflow will open in App Mode by default from now on.",
"defaultModeAppliedAppPrompt": "Would you like to view it now?",
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
"viewApp": "View app",
"exitToWorkflow": "Exit to workflow",
"saveAs": "Save as",
"filename": "Filename",
"emptyWorkflowTitle": "This workflow has no nodes",
"emptyWorkflowPrompt": "Do you want to start with a template?"
},
"builderFooter": {
"opensAsApp": "Open as an {mode}",
"opensAsGraph": "Open as a {mode}"
},
"builderSave": {
"successTitle": "Successfully saved",
"successBody": "Would you like to view it now?",
"successBodyApp": "This workflow will open in App Mode by default from now on.\n\nWould you like to view it now?",
"successBodyGraph": "This workflow will open as a node graph."
},
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"

View File

@@ -3996,11 +3996,19 @@
},
"floats": {
"name": "floats",
"tooltip": "Floats are available as u_float0-4 in the shader code"
"tooltip": "Floats are available as u_float0-19 in the shader code"
},
"ints": {
"name": "ints",
"tooltip": "Ints are available as u_int0-4 in the shader code"
"tooltip": "Ints are available as u_int0-19 in the shader code"
},
"bools": {
"name": "bools",
"tooltip": "Booleans are available as u_bool0-9 (bool) in the shader code"
},
"curves": {
"name": "curves",
"tooltip": "Curves are available as u_curve0-3 (sampler2D, 1D LUT) in the shader code. Sample with texture(u_curve0, vec2(x, 0.5)).r"
}
},
"outputs": {

View File

@@ -347,10 +347,20 @@
"share": "Compartir",
"workflowActions": "Acciones del flujo de trabajo"
},
"builderFooter": {
"opensAsApp": "Abrir como {mode}",
"opensAsGraph": "Abrir como {mode}"
},
"builderMenu": {
"enterAppMode": "Entrar en modo aplicación",
"exitAppBuilder": "Salir del constructor de aplicaciones"
},
"builderSave": {
"successBody": "¿Te gustaría verlo ahora?",
"successBodyApp": "Este flujo de trabajo se abrirá en Modo App por defecto de ahora en adelante.\n\n¿Te gustaría verlo ahora?",
"successBodyGraph": "Este flujo de trabajo se abrirá como un grafo de nodos.",
"successTitle": "Guardado exitosamente"
},
"builderToolbar": {
"app": "Aplicación",
"appDescription": "Se abre como una aplicación por defecto",
@@ -359,18 +369,10 @@
"connectOutput": "Conectar una salida",
"connectOutputBody1": "Tu aplicación necesita al menos una salida conectada antes de poder guardarse.",
"connectOutputBody2": "Cambia al paso 'Seleccionar' y haz clic en los nodos de salida para agregarlos aquí.",
"defaultModeAppliedAppBody": "Este flujo de trabajo se abrirá en Modo de Aplicación por defecto a partir de ahora.",
"defaultModeAppliedAppPrompt": "¿Te gustaría verlo ahora?",
"defaultModeAppliedGraphBody": "Este flujo de trabajo se abrirá como un grafo de nodos por defecto a partir de ahora.",
"defaultModeAppliedGraphPrompt": "¿Aún quieres ver la aplicación?",
"defaultModeAppliedTitle": "Configurado correctamente",
"defaultView": "Establecer vista predeterminada",
"defaultViewDescription": "Elige cómo se abre esto",
"defaultViewLabel": "Por defecto, este flujo de trabajo se abrirá como:",
"defaultViewTitle": "Establecer la vista predeterminada para este flujo de trabajo",
"emptyWorkflowPrompt": "¿Quieres empezar con una plantilla?",
"emptyWorkflowTitle": "Este flujo de trabajo no tiene nodos",
"exitToWorkflow": "Salir al flujo de trabajo",
"filename": "Nombre de archivo",
"inputs": "Entradas",
"inputsDescription": "Elige entradas",
"label": "Constructor de aplicaciones",
@@ -378,6 +380,7 @@
"nodeGraphDescription": "Se abre como grafo de nodos por defecto",
"outputs": "Salidas",
"outputsDescription": "Elige salidas",
"saveAs": "Guardar como",
"switchToOutputs": "Cambiar a Salidas",
"viewApp": "Ver aplicación"
},
@@ -898,7 +901,15 @@
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORES",
"seeErrors": "Ver errores"
"missingMedia": "A algunos nodos les faltan entradas requeridas",
"missingModels": "Falta {count} modelo requerido | Faltan {count} modelos requeridos",
"missingNodes": "Faltan algunos nodos y necesitan ser instalados",
"seeErrors": "Ver errores",
"showMissingMedia": "Mostrar entradas faltantes",
"showMissingModels": "Mostrar modelos faltantes",
"showMissingNodes": "Mostrar nodos faltantes",
"showSwapNodes": "Mostrar nodos intercambiables",
"swapNodes": "Algunos nodos pueden ser reemplazados por alternativas"
},
"essentials": {
"batchImage": "Procesar imágenes por lotes",

View File

@@ -3645,6 +3645,14 @@
"description": "Aplica shaders de fragmento GLSL ES a imágenes. u_resolution (vec2) siempre está disponible.",
"display_name": "Shader GLSL",
"inputs": {
"bools": {
"name": "booleanos",
"tooltip": "Los booleanos están disponibles como u_bool0-9 (bool) en el código del shader"
},
"curves": {
"name": "curvas",
"tooltip": "Las curvas están disponibles como u_curve0-3 (sampler2D, LUT 1D) en el código del shader. Muestrea con texture(u_curve0, vec2(x, 0.5)).r"
},
"floats": {
"name": "floats",
"tooltip": "Los floats están disponibles como u_float0-4 en el código del shader"

View File

@@ -347,10 +347,20 @@
"share": "اشتراک‌گذاری",
"workflowActions": "عملیات گردش‌کار"
},
"builderFooter": {
"opensAsApp": "باز کردن به صورت {mode}",
"opensAsGraph": "باز کردن به صورت {mode}"
},
"builderMenu": {
"enterAppMode": "ورود به حالت برنامه",
"exitAppBuilder": "خروج از سازنده برنامه"
},
"builderSave": {
"successBody": "آیا مایل هستید اکنون آن را مشاهده کنید؟",
"successBodyApp": "این workflow از این پس به طور پیش‌فرض در حالت App Mode باز خواهد شد.\n\nآیا مایل هستید اکنون آن را مشاهده کنید؟",
"successBodyGraph": "این workflow به صورت یک گراف نود باز خواهد شد.",
"successTitle": "با موفقیت ذخیره شد"
},
"builderToolbar": {
"app": "اپلیکیشن",
"appDescription": "به طور پیش‌فرض به صورت اپلیکیشن باز می‌شود",
@@ -359,18 +369,10 @@
"connectOutput": "اتصال خروجی",
"connectOutputBody1": "اپلیکیشن شما باید حداقل یک خروجی متصل داشته باشد تا بتوان آن را ذخیره کرد.",
"connectOutputBody2": "به مرحله «انتخاب» بروید و روی nodeهای خروجی کلیک کنید تا اینجا اضافه شوند.",
"defaultModeAppliedAppBody": "این ورک‌فلو از این پس به طور پیش‌فرض در حالت اپلیکیشن باز خواهد شد.",
"defaultModeAppliedAppPrompt": "آیا مایل هستید اکنون آن را مشاهده کنید؟",
"defaultModeAppliedGraphBody": "این ورک‌فلو از این پس به طور پیش‌فرض به صورت گراف node باز خواهد شد.",
"defaultModeAppliedGraphPrompt": "آیا همچنان مایل به مشاهده اپلیکیشن هستید؟",
"defaultModeAppliedTitle": "با موفقیت تنظیم شد",
"defaultView": "تنظیم نمای پیش‌فرض",
"defaultViewDescription": "انتخاب نحوه باز شدن",
"defaultViewLabel": "به طور پیش‌فرض، این گردش‌کار به صورت زیر باز می‌شود:",
"defaultViewTitle": "تنظیم نمای پیش‌فرض برای این گردش‌کار",
"emptyWorkflowPrompt": "آیا می‌خواهید با یک قالب شروع کنید؟",
"emptyWorkflowTitle": "این ورک‌فلو هیچ node ندارد",
"exitToWorkflow": "بازگشت به workflow",
"filename": "نام فایل",
"inputs": "ورودی‌ها",
"inputsDescription": "انتخاب ورودی‌ها",
"label": "سازنده اپلیکیشن",
@@ -378,6 +380,7 @@
"nodeGraphDescription": "به طور پیش‌فرض به صورت گراف node باز می‌شود",
"outputs": "خروجی‌ها",
"outputsDescription": "انتخاب خروجی‌ها",
"saveAs": "ذخیره به عنوان",
"switchToOutputs": "رفتن به خروجی‌ها",
"viewApp": "مشاهده اپلیکیشن"
},
@@ -898,7 +901,15 @@
},
"errorOverlay": {
"errorCount": "{count} خطا",
"seeErrors": "مشاهده خطاها"
"missingMedia": "برخی از نودها ورودی‌های مورد نیاز را ندارند",
"missingModels": "{count} مدل مورد نیاز مفقود است | {count} مدل مورد نیاز مفقود هستند",
"missingNodes": "برخی از نودها مفقود هستند و باید نصب شوند",
"seeErrors": "مشاهده خطاها",
"showMissingMedia": "نمایش ورودی‌های مفقود",
"showMissingModels": "نمایش مدل‌های مفقود",
"showMissingNodes": "نمایش نودهای مفقود",
"showSwapNodes": "نمایش نودهای قابل جایگزینی",
"swapNodes": "برخی از نودها را می‌توان با گزینه‌های جایگزین تعویض کرد"
},
"essentials": {
"batchImage": "پردازش دسته‌ای تصویر",

View File

@@ -3645,6 +3645,14 @@
"description": "اعمال شیدرهای fragment GLSL ES به تصاویر. u_resolution (vec2) همیشه در دسترس است.",
"display_name": "GLSL Shader",
"inputs": {
"bools": {
"name": "بول‌ها",
"tooltip": "بولین‌ها به صورت u_bool0-9 (bool) در کد شیدر در دسترس هستند"
},
"curves": {
"name": "منحنی‌ها",
"tooltip": "منحنی‌ها به صورت u_curve0-3 (sampler2D، ۱D LUT) در کد شیدر در دسترس هستند. با texture(u_curve0, vec2(x, 0.5)).r نمونه‌برداری کنید."
},
"floats": {
"name": "floats",
"tooltip": "اعداد اعشاری به صورت u_float0-4 در کد شیدر در دسترس هستند"

View File

@@ -347,10 +347,20 @@
"share": "Partager",
"workflowActions": "Actions du workflow"
},
"builderFooter": {
"opensAsApp": "Ouvrir en tant que {mode}",
"opensAsGraph": "Ouvrir en tant que {mode}"
},
"builderMenu": {
"enterAppMode": "Entrer en mode application",
"exitAppBuilder": "Quitter le constructeur d'application"
},
"builderSave": {
"successBody": "Voulez-vous le consulter maintenant ?",
"successBodyApp": "Ce workflow souvrira désormais par défaut en mode Application.\n\nVoulez-vous le consulter maintenant ?",
"successBodyGraph": "Ce workflow souvrira sous forme de graphe de nœuds.",
"successTitle": "Enregistré avec succès"
},
"builderToolbar": {
"app": "Application",
"appDescription": "S'ouvre par défaut en tant qu'application",
@@ -359,18 +369,10 @@
"connectOutput": "Connecter une sortie",
"connectOutputBody1": "Votre application doit avoir au moins une sortie connectée avant de pouvoir être enregistrée.",
"connectOutputBody2": "Passez à l'étape « Sélectionner » et cliquez sur les nœuds de sortie pour les ajouter ici.",
"defaultModeAppliedAppBody": "Ce workflow souvrira désormais par défaut en mode Application.",
"defaultModeAppliedAppPrompt": "Voulez-vous le voir maintenant ?",
"defaultModeAppliedGraphBody": "Ce workflow souvrira désormais par défaut sous forme de graphe de nœuds.",
"defaultModeAppliedGraphPrompt": "Voulez-vous tout de même voir lapplication ?",
"defaultModeAppliedTitle": "Défini avec succès",
"defaultView": "Définir une vue par défaut",
"defaultViewDescription": "Choisissez comment cela s'ouvre",
"defaultViewLabel": "Par défaut, ce workflow s'ouvrira comme :",
"defaultViewTitle": "Définir la vue par défaut pour ce workflow",
"emptyWorkflowPrompt": "Voulez-vous commencer avec un modèle ?",
"emptyWorkflowTitle": "Ce workflow ne contient aucun nœud",
"exitToWorkflow": "Quitter vers le workflow",
"filename": "Nom du fichier",
"inputs": "Entrées",
"inputsDescription": "Choisissez les entrées",
"label": "Créateur d'applications",
@@ -378,6 +380,7 @@
"nodeGraphDescription": "S'ouvre par défaut en tant que graphe de nœuds",
"outputs": "Sorties",
"outputsDescription": "Choisissez les sorties",
"saveAs": "Enregistrer sous",
"switchToOutputs": "Passer aux sorties",
"viewApp": "Voir lapplication"
},
@@ -898,7 +901,15 @@
},
"errorOverlay": {
"errorCount": "{count} ERREUR | {count} ERREURS",
"seeErrors": "Voir les erreurs"
"missingMedia": "Certains nœuds n'ont pas les entrées requises",
"missingModels": "{count} modèle requis est manquant | {count} modèles requis sont manquants",
"missingNodes": "Certains nœuds sont manquants et doivent être installés",
"seeErrors": "Voir les erreurs",
"showMissingMedia": "Afficher les entrées manquantes",
"showMissingModels": "Afficher les modèles manquants",
"showMissingNodes": "Afficher les nœuds manquants",
"showSwapNodes": "Afficher les nœuds de remplacement",
"swapNodes": "Certains nœuds peuvent être remplacés par des alternatives"
},
"essentials": {
"batchImage": "Traitement par lot d'images",

View File

@@ -3645,6 +3645,14 @@
"description": "Appliquez des shaders de fragments GLSL ES aux images. u_resolution (vec2) est toujours disponible.",
"display_name": "Shader GLSL",
"inputs": {
"bools": {
"name": "booléens",
"tooltip": "Les booléens sont disponibles sous u_bool0-9 (bool) dans le code du shader"
},
"curves": {
"name": "courbes",
"tooltip": "Les courbes sont disponibles sous u_curve0-3 (sampler2D, LUT 1D) dans le code du shader. Échantillonnez avec texture(u_curve0, vec2(x, 0.5)).r"
},
"floats": {
"name": "floats",
"tooltip": "Les flottants sont disponibles sous u_float0-4 dans le code du shader"

View File

@@ -347,10 +347,20 @@
"share": "共有",
"workflowActions": "ワークフロー操作"
},
"builderFooter": {
"opensAsApp": "{mode}として開く",
"opensAsGraph": "{mode}として開く"
},
"builderMenu": {
"enterAppMode": "アプリモードに入る",
"exitAppBuilder": "アプリビルダーを終了"
},
"builderSave": {
"successBody": "今すぐ表示しますか?",
"successBodyApp": "このワークフローは今後デフォルトでアプリモードで開かれます。\n\n今すぐ表示しますか",
"successBodyGraph": "このワークフローはノードグラフとして開かれます。",
"successTitle": "保存に成功しました"
},
"builderToolbar": {
"app": "アプリ",
"appDescription": "デフォルトでアプリとして開きます",
@@ -359,18 +369,10 @@
"connectOutput": "出力を接続",
"connectOutputBody1": "アプリを保存するには、少なくとも1つの出力を接続する必要があります。",
"connectOutputBody2": "「選択」ステップに切り替えて、出力ノードをクリックしてここに追加してください。",
"defaultModeAppliedAppBody": "このワークフローは今後デフォルトでアプリモードで開きます。",
"defaultModeAppliedAppPrompt": "今すぐ表示しますか?",
"defaultModeAppliedGraphBody": "このワークフローは今後デフォルトでノードグラフとして開きます。",
"defaultModeAppliedGraphPrompt": "アプリを引き続き表示しますか?",
"defaultModeAppliedTitle": "正常に設定されました",
"defaultView": "デフォルトビューを設定",
"defaultViewDescription": "開き方を選択してください",
"defaultViewLabel": "デフォルトでは、このワークフローは次のように開きます:",
"defaultViewTitle": "このワークフローのデフォルトビューを設定",
"emptyWorkflowPrompt": "テンプレートから始めますか?",
"emptyWorkflowTitle": "このワークフローにはノードがありません",
"exitToWorkflow": "ワークフローに戻る",
"filename": "ファイル名",
"inputs": "入力",
"inputsDescription": "入力を選択",
"label": "アプリビルダー",
@@ -378,6 +380,7 @@
"nodeGraphDescription": "デフォルトでノードグラフとして開きます",
"outputs": "出力",
"outputsDescription": "出力を選択",
"saveAs": "名前を付けて保存",
"switchToOutputs": "出力に切り替え",
"viewApp": "アプリを表示"
},
@@ -898,7 +901,15 @@
},
"errorOverlay": {
"errorCount": "{count} 件のエラー",
"seeErrors": "エラーを表示"
"missingMedia": "いくつかのノードに必要な入力が不足しています",
"missingModels": "{count} 個の必要なモデルが不足しています",
"missingNodes": "いくつかのノードが不足しており、インストールが必要です",
"seeErrors": "エラーを表示",
"showMissingMedia": "不足している入力を表示",
"showMissingModels": "不足しているモデルを表示",
"showMissingNodes": "不足しているノードを表示",
"showSwapNodes": "代替可能なノードを表示",
"swapNodes": "いくつかのノードは代替可能です"
},
"essentials": {
"batchImage": "バッチ画像処理",

View File

@@ -3645,6 +3645,14 @@
"description": "GLSL ESフラグメントシェーダーを画像に適用します。u_resolutionvec2は常に利用可能です。",
"display_name": "GLSLシェーダー",
"inputs": {
"bools": {
"name": "bools",
"tooltip": "ブール値はシェーダーコード内で u_bool0-9boolとして利用できます"
},
"curves": {
"name": "curves",
"tooltip": "カーブはシェーダーコード内で u_curve0-3sampler2D、1D LUTとして利用できます。texture(u_curve0, vec2(x, 0.5)).r でサンプリングします"
},
"floats": {
"name": "floats",
"tooltip": "floatsはシェーダーコード内でu_float0-4として利用可能"

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