Compare commits

...

13 Commits

Author SHA1 Message Date
Christian Byrne
62979e3818 refactor: rename firebaseAuthStore to authStore with shared test fixtures (#10483)
## Summary

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

## Changes

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

## Review Focus

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

Fixes #8219

## Stack

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

---------

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

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

## Screenshots
 
after


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

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

---------

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

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

## Changes

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

## Review Focus

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

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

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

## Summary

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

- N/A

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

---------

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

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

## Changes

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

## Review Focus

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

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

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

Fix #10458 

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

Fixes #10590

## Screenshots (if applicable)

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

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

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

## Changes

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

## Review Focus

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

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

Fixes COM-306

## Screenshots (if applicable)

Not applicable.

## Validation

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

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

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

---------

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

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

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

fix #10456

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

**Base branch:** `main`

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

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

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

Stacked on #10471.

## Changes

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

## Review Focus

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

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

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-26 19:46:31 -07:00
103 changed files with 1484 additions and 751 deletions

View File

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

View File

@@ -19,10 +19,12 @@ import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
@@ -55,6 +57,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -78,6 +81,11 @@ class ComfyMenu {
return this._nodeLibraryTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
}
get workflowsTab() {
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
return this._workflowsTab
@@ -192,6 +200,7 @@ export class ComfyPage {
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
@@ -238,6 +247,7 @@ export class ComfyPage {
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}

View File

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

View File

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

View File

@@ -20,7 +20,12 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
zoomOutAction: 'zoom-out-action',
zoomToFitAction: 'zoom-to-fit-action',
zoomPercentageInput: 'zoom-percentage-input'
},
dialogs: {
settings: 'settings-dialog',

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -39,8 +40,8 @@ describe('SearchInput', () => {
vi.useRealTimers()
})
function mountComponent(props = {}) {
return mount(SearchInput, {
function renderComponent(props = {}) {
const result = render(SearchInput, {
global: {
plugins: [i18n],
stubs: {
@@ -63,140 +64,142 @@ describe('SearchInput', () => {
...props
}
})
return result
}
describe('debounced search', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const onSearch = vi.fn()
renderComponent({ onSearch })
const input = screen.getByRole('textbox')
await input.setValue('test')
await fireEvent.update(input, 'test')
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.emitted('search')).toEqual([['test']])
expect(onSearch).toHaveBeenCalledWith('test')
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const onSearch = vi.fn()
renderComponent({ onSearch })
const input = screen.getByRole('textbox')
await input.setValue('t')
await fireEvent.update(input, 't')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('te')
await fireEvent.update(input, 'te')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('tes')
await fireEvent.update(input, 'tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
expect(onSearch).toHaveBeenCalled()
expect(onSearch).toHaveBeenCalledWith('tes')
})
it('should only emit final value after rapid typing', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const onSearch = vi.fn()
renderComponent({ onSearch })
const input = screen.getByRole('textbox')
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await fireEvent.update(input, term)
await vi.advanceTimersByTimeAsync(50)
}
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
expect(onSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(350)
await nextTick()
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
expect(onSearch).toHaveBeenCalledTimes(1)
expect(onSearch).toHaveBeenCalledWith('search')
})
})
describe('model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = mountComponent({ modelValue: 'initial' })
const input = wrapper.find('input')
const { rerender } = renderComponent({ modelValue: 'initial' })
const input = screen.getByRole('textbox')
expect(input.element.value).toBe('initial')
expect(input).toHaveValue('initial')
await wrapper.setProps({ modelValue: 'external update' })
await rerender({ modelValue: 'external update' })
await nextTick()
expect(input.element.value).toBe('external update')
expect(input).toHaveValue('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = mountComponent({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
renderComponent({ placeholder: 'Custom search...' })
expect(
screen.getByPlaceholderText('Custom search...')
).toBeInTheDocument()
})
it('should use i18n placeholder when not provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
renderComponent()
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
})
})
describe('autofocus', () => {
it('should pass autofocus prop to ComboboxInput', () => {
const wrapper = mountComponent({ autofocus: true })
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeDefined()
renderComponent({ autofocus: true })
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('autofocus')
})
it('should not autofocus by default', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeUndefined()
})
})
describe('focus method', () => {
it('should expose focus method via ref', () => {
const wrapper = mountComponent()
expect(wrapper.vm.focus).toBeDefined()
renderComponent()
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('autofocus')
})
})
describe('clear button', () => {
it('shows search icon when value is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
renderComponent({ modelValue: '' })
expect(
screen.queryByRole('button', { name: 'Clear' })
).not.toBeInTheDocument()
})
it('shows clear button when value is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
renderComponent({ modelValue: 'test' })
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
})
it('clears value when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
const onUpdate = vi.fn()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent({
modelValue: 'test',
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(onUpdate).toHaveBeenCalledWith('')
})
})
})

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -16,29 +17,39 @@ const i18n = createI18n({
})
describe('TagsInput', () => {
function mountTagsInput(props = {}, slots = {}) {
return mount(TagsInput, {
function renderTagsInput(props = {}, slots = {}) {
const user = userEvent.setup()
const result = render(TagsInput, {
props: {
modelValue: [],
...props
},
slots
})
return { ...result, user }
}
it('renders slot content', () => {
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
renderTagsInput({}, { default: '<span>Slot Content</span>' })
expect(wrapper.text()).toContain('Slot Content')
expect(screen.getByText('Slot Content')).toBeInTheDocument()
})
})
describe('TagsInput with child components', () => {
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
return mount(TagsInput, {
function renderFullTagsInput(
tags: string[] = ['tag1', 'tag2'],
extraProps: Record<string, unknown> = {}
) {
const user = userEvent.setup()
const result = render(TagsInput, {
global: { plugins: [i18n] },
props: {
modelValue: tags
modelValue: tags,
...extraProps
},
slots: {
default: () => [
@@ -52,55 +63,52 @@ describe('TagsInput with child components', () => {
]
}
})
return { ...result, user }
}
it('renders tags structure and content', () => {
const tags = ['tag1', 'tag2']
const wrapper = mountFullTagsInput(tags)
renderFullTagsInput(tags)
const items = wrapper.findAllComponents(TagsInputItem)
const textElements = wrapper.findAllComponents(TagsInputItemText)
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
expect(screen.getByText('tag1')).toBeInTheDocument()
expect(screen.getByText('tag2')).toBeInTheDocument()
expect(items).toHaveLength(tags.length)
expect(textElements).toHaveLength(tags.length)
const deleteButtons = tags.map((tag) =>
screen.getByRole('button', { name: tag })
)
expect(deleteButtons).toHaveLength(tags.length)
textElements.forEach((el, i) => {
expect(el.text()).toBe(tags[i])
})
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
})
it('updates model value when adding a tag', async () => {
let currentTags = ['existing']
const onUpdate = vi.fn()
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
const user = userEvent.setup()
const { container } = render(TagsInput, {
props: {
modelValue: currentTags,
'onUpdate:modelValue': (payload) => {
currentTags = payload
}
modelValue: ['existing'],
'onUpdate:modelValue': onUpdate
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
}
})
await wrapper.trigger('click')
// Click the container to enter edit mode and show the input
// eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click to enter edit mode; no role/label available
await user.click(container.firstElementChild!)
await nextTick()
const input = wrapper.find('input')
await input.setValue('newTag')
await input.trigger('keydown', { key: 'Enter' })
const input = screen.getByPlaceholderText('Add tag...')
await user.type(input, 'newTag{Enter}')
await nextTick()
expect(currentTags).toContain('newTag')
expect(onUpdate).toHaveBeenCalledWith(['existing', 'newTag'])
})
it('does not enter edit mode when disabled', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
const user = userEvent.setup()
const { container } = render(TagsInput, {
props: {
modelValue: ['tag1'],
disabled: true
@@ -110,18 +118,21 @@ describe('TagsInput with child components', () => {
}
})
expect(wrapper.find('input').exists()).toBe(false)
expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument()
await wrapper.trigger('click')
// eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click to test disabled behavior; no role/label available
await user.click(container.firstElementChild!)
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument()
})
it('exits edit mode when clicking outside', async () => {
const outsideElement = document.createElement('div')
document.body.appendChild(outsideElement)
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
const user = userEvent.setup()
const { container } = render(TagsInput, {
props: {
modelValue: ['tag1']
},
@@ -130,21 +141,21 @@ describe('TagsInput with child components', () => {
}
})
await wrapper.trigger('click')
// eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click; no role/label
await user.click(container.firstElementChild!)
await nextTick()
expect(wrapper.find('input').exists()).toBe(true)
expect(screen.getByPlaceholderText('Add tag...')).toBeInTheDocument()
outsideElement.dispatchEvent(new PointerEvent('click', { bubbles: true }))
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument()
wrapper.unmount()
outsideElement.remove()
})
it('shows placeholder when modelValue is empty', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
render(TagsInput, {
props: {
modelValue: []
},
@@ -156,8 +167,7 @@ describe('TagsInput with child components', () => {
await nextTick()
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toBe('Add tag...')
const input = screen.getByPlaceholderText('Add tag...')
expect(input).toBeInTheDocument()
})
})

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

@@ -67,7 +67,14 @@ Sentry.init({
replaysOnErrorSampleRate: 0,
// Only set these for non-cloud builds
...(isCloud
? {}
? {
integrations: [
// Disable event target wrapping to reduce overhead on high-frequency
// DOM events (pointermove, mousemove, wheel). Sentry still captures
// errors via window.onerror and unhandledrejection.
Sentry.browserApiErrorsIntegration({ eventTarget: false })
]
}
: {
integrations: [],
autoSessionTracking: false,

View File

@@ -141,6 +141,7 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -279,7 +280,8 @@ const formattedDuration = computed(() => {
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && imageDimensions.value) {
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {

View File

@@ -1,7 +1,7 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
/**
* Session cookie management for cloud authentication.
@@ -21,7 +21,7 @@ export const useSessionCookie = () => {
const { flags } = useFeatureFlags()
try {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
let authHeader: Record<string, string>

View File

@@ -0,0 +1,135 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import DesktopCloudNotificationController from './DesktopCloudNotificationController.vue'
const settingState = {
shown: false
}
const settingStore = {
load: vi.fn<() => Promise<void>>(),
get: vi.fn((key: string) =>
key === 'Comfy.Desktop.CloudNotificationShown'
? settingState.shown
: undefined
),
set: vi.fn(async (_key: string, value: boolean) => {
settingState.shown = value
})
}
const dialogService = {
showCloudNotification: vi.fn<() => Promise<void>>()
}
const electron = {
getPlatform: vi.fn(() => 'darwin')
}
vi.mock('@/platform/distribution/types', () => ({
isDesktop: true
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => dialogService
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => electron
}))
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('DesktopCloudNotificationController', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
settingState.shown = false
electron.getPlatform.mockReturnValue('darwin')
settingStore.load.mockResolvedValue(undefined)
settingStore.set.mockImplementation(
async (_key: string, value: boolean) => {
settingState.shown = value
}
)
dialogService.showCloudNotification.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('waits for settings to load before deciding whether to show the notification', async () => {
const loadSettings = createDeferred()
settingStore.load.mockImplementation(() => loadSettings.promise)
const wrapper = mount(DesktopCloudNotificationController)
await nextTick()
settingState.shown = true
loadSettings.resolve()
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(dialogService.showCloudNotification).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not schedule or show the notification after unmounting before settings load resolves', async () => {
const loadSettings = createDeferred()
settingStore.load.mockImplementation(() => loadSettings.promise)
const wrapper = mount(DesktopCloudNotificationController)
await nextTick()
wrapper.unmount()
loadSettings.resolve()
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(settingStore.set).not.toHaveBeenCalled()
expect(dialogService.showCloudNotification).not.toHaveBeenCalled()
})
it('marks the notification as shown before awaiting dialog close', async () => {
const dialogOpen = createDeferred()
dialogService.showCloudNotification.mockImplementation(
() => dialogOpen.promise
)
const wrapper = mount(DesktopCloudNotificationController)
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Desktop.CloudNotificationShown',
true
)
expect(settingStore.set.mock.invocationCallOrder[0]).toBeLessThan(
dialogService.showCloudNotification.mock.invocationCallOrder[0]
)
dialogOpen.resolve()
await flushPromises()
wrapper.unmount()
})
})

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { electronAPI } from '@/utils/envUtil'
const settingStore = useSettingStore()
const dialogService = useDialogService()
let isDisposed = false
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
async function scheduleCloudNotification() {
if (!isDesktop || electronAPI()?.getPlatform() !== 'darwin') return
try {
await settingStore.load()
} catch (error) {
console.warn('[CloudNotification] Failed to load settings', error)
return
}
if (isDisposed) return
if (settingStore.get('Comfy.Desktop.CloudNotificationShown')) return
cloudNotificationTimer = setTimeout(async () => {
if (isDisposed) return
try {
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
if (isDisposed) return
await dialogService.showCloudNotification()
} catch (error) {
console.warn('[CloudNotification] Failed to show', error)
await settingStore
.set('Comfy.Desktop.CloudNotificationShown', false)
.catch((resetError) => {
console.warn(
'[CloudNotification] Failed to reset shown state',
resetError
)
})
}
}, 2000)
}
onMounted(() => {
void scheduleCloudNotification()
})
onUnmounted(() => {
isDisposed = true
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
})
</script>

View File

@@ -74,7 +74,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
interface Props {
errorMessage?: string
@@ -83,7 +83,7 @@ interface Props {
defineProps<Props>()
const router = useRouter()
const { logout } = useFirebaseAuthActions()
const { logout } = useAuthActions()
const showTechnicalDetails = ref(false)
const handleRestart = async () => {

View File

@@ -76,11 +76,11 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
const { t } = useI18n()
const router = useRouter()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const email = ref('')
const loading = ref(false)

View File

@@ -110,7 +110,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
@@ -120,7 +120,7 @@ import type { SignInData } from '@/schemas/signInSchema'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()

View File

@@ -42,7 +42,7 @@
</Button>
<span
v-if="isFreeTierEnabled"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-[10px] font-bold whitespace-nowrap text-gray-900"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
>
{{ t('auth.login.freeTierBadge') }}
</span>
@@ -133,7 +133,7 @@ import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
@@ -145,7 +145,7 @@ import { isInChina } from '@/utils/networkUtil'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)

View File

@@ -25,8 +25,8 @@ const authActionMocks = vi.hoisted(() => ({
accessBillingPortal: vi.fn()
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => authActionMocks
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => authActionMocks
}))
vi.mock('@/composables/useErrorHandling', () => ({

View File

@@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
@@ -16,7 +16,7 @@ import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { reportError, accessBillingPortal } = useAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isActiveSubscription, isInitialized, initialize } = useBillingContext()

View File

@@ -89,9 +89,9 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()

View File

@@ -31,8 +31,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal,
reportError: mockReportError
})
@@ -56,13 +56,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () =>
vi.mock('@/stores/authStore', () => ({
useAuthStore: () =>
reactive({
getAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
}),
FirebaseAuthStoreError: class extends Error {}
AuthStoreError: class extends Error {}
}))
vi.mock('@/platform/telemetry', () => ({

View File

@@ -30,7 +30,7 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-[11px] font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
>
-20%
</div>
@@ -58,7 +58,7 @@
</span>
<div
v-if="tier.isPopular"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-[11px] font-bold tracking-tight text-base-background uppercase"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-2xs font-bold tracking-tight text-base-background uppercase"
>
{{ t('subscription.mostPopular') }}
</div>
@@ -262,7 +262,7 @@ import { computed, 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 { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
@@ -279,7 +279,7 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
@@ -365,8 +365,8 @@ const {
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry()
const { userId } = storeToRefs(useFirebaseAuthStore())
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)

View File

@@ -82,8 +82,8 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
authActions: vi.fn(() => ({
accessBillingPortal: vi.fn()
}))

View File

@@ -211,7 +211,7 @@ import { computed, onBeforeUnmount, onMounted } 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 SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
@@ -227,7 +227,7 @@ import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefi
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const { t, n } = useI18n()
const {

View File

@@ -65,8 +65,8 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
reportError: mockReportError,
accessBillingPortal: mockAccessBillingPortal
}))
@@ -106,14 +106,14 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
get userId() {
return mockUserId.value
}
})),
FirebaseAuthStoreError: class extends Error {}
AuthStoreError: class extends Error {}
}))
// Mock fetch

View File

@@ -2,7 +2,7 @@ import { computed, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
@@ -10,10 +10,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -37,11 +34,11 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { reportError, accessBillingPortal } = useAuthActions()
const { showSubscriptionRequiredDialog } = useDialogService()
const firebaseAuthStore = useFirebaseAuthStore()
const { getAuthHeader } = firebaseAuthStore
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -194,7 +191,7 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
@@ -209,7 +206,7 @@ function useSubscriptionInternal() {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
@@ -248,9 +245,7 @@ function useSubscriptionInternal() {
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutAttribution = await getCheckoutAttributionForCloud()
@@ -268,7 +263,7 @@ function useSubscriptionInternal() {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorData.message
})

View File

@@ -8,8 +8,8 @@ const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))

View File

@@ -1,7 +1,7 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -12,7 +12,7 @@ import { useCommandStore } from '@/stores/commandStore'
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus } = useBillingContext()

View File

@@ -36,14 +36,14 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() =>
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() =>
reactive({
getAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
})
),
FirebaseAuthStoreError: class extends Error {}
AuthStoreError: class extends Error {}
}))
vi.mock('@/platform/distribution/types', () => ({

View File

@@ -4,10 +4,7 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'
@@ -51,13 +48,13 @@ export async function performSubscriptionCheckout(
): Promise<void> {
if (!isCloud) return
const firebaseAuthStore = useFirebaseAuthStore()
const { userId } = storeToRefs(firebaseAuthStore)
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
const authHeader = await firebaseAuthStore.getAuthHeader()
const authHeader = await authStore.getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
@@ -97,7 +94,7 @@ export async function performSubscriptionCheckout(
}
}
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorMessage
})

View File

@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -129,7 +129,7 @@ const {
getSearchResults
} = useSettingSearch()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)

View File

@@ -6,7 +6,7 @@ type MockApiKeyUser = {
email?: string
} | null
type MockFirebaseUser = {
type MockAuthUser = {
uid: string
email?: string | null
} | null
@@ -14,19 +14,19 @@ type MockFirebaseUser = {
const {
mockCaptureCheckoutAttributionFromSearch,
mockUseApiKeyAuthStore,
mockUseFirebaseAuthStore,
mockUseAuthStore,
mockApiKeyAuthStore,
mockFirebaseAuthStore
mockAuthStore
} = vi.hoisted(() => ({
mockCaptureCheckoutAttributionFromSearch: vi.fn(),
mockUseApiKeyAuthStore: vi.fn(),
mockUseFirebaseAuthStore: vi.fn(),
mockUseAuthStore: vi.fn(),
mockApiKeyAuthStore: {
isAuthenticated: false,
currentUser: null as MockApiKeyUser
},
mockFirebaseAuthStore: {
currentUser: null as MockFirebaseUser
mockAuthStore: {
currentUser: null as MockAuthUser
}
}))
@@ -38,8 +38,8 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: mockUseApiKeyAuthStore
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: mockUseFirebaseAuthStore
vi.mock('@/stores/authStore', () => ({
useAuthStore: mockUseAuthStore
}))
import { ImpactTelemetryProvider } from './ImpactTelemetryProvider'
@@ -64,14 +64,14 @@ describe('ImpactTelemetryProvider', () => {
beforeEach(() => {
mockCaptureCheckoutAttributionFromSearch.mockReset()
mockUseApiKeyAuthStore.mockReset()
mockUseFirebaseAuthStore.mockReset()
mockUseAuthStore.mockReset()
mockApiKeyAuthStore.isAuthenticated = false
mockApiKeyAuthStore.currentUser = null
mockFirebaseAuthStore.currentUser = null
mockAuthStore.currentUser = null
vi.restoreAllMocks()
vi.unstubAllGlobals()
mockUseApiKeyAuthStore.mockReturnValue(mockApiKeyAuthStore)
mockUseFirebaseAuthStore.mockReturnValue(mockFirebaseAuthStore)
mockUseAuthStore.mockReturnValue(mockAuthStore)
const queueFn: NonNullable<Window['ire']> = (...args: unknown[]) => {
;(queueFn.a ??= []).push(args)
@@ -93,7 +93,7 @@ describe('ImpactTelemetryProvider', () => {
})
it('captures attribution and invokes identify with hashed email', async () => {
mockFirebaseAuthStore.currentUser = {
mockAuthStore.currentUser = {
uid: 'user-123',
email: ' User@Example.com '
}
@@ -153,7 +153,7 @@ describe('ImpactTelemetryProvider', () => {
})
it('invokes identify on each page view even with identical identity payloads', async () => {
mockFirebaseAuthStore.currentUser = {
mockAuthStore.currentUser = {
uid: 'user-123',
email: 'user@example.com'
}
@@ -189,7 +189,7 @@ describe('ImpactTelemetryProvider', () => {
id: 'api-key-user-123',
email: 'apikey@example.com'
}
mockFirebaseAuthStore.currentUser = {
mockAuthStore.currentUser = {
uid: 'firebase-user-123',
email: 'firebase@example.com'
}
@@ -228,7 +228,7 @@ describe('ImpactTelemetryProvider', () => {
id: 'api-key-user-123',
email: 'apikey@example.com'
}
mockFirebaseAuthStore.currentUser = null
mockAuthStore.currentUser = null
vi.stubGlobal('crypto', {
subtle: {
digest: vi.fn(

View File

@@ -1,6 +1,6 @@
import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { PageViewMetadata, TelemetryProvider } from '../../types'
@@ -17,7 +17,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
private initialized = false
private stores: {
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
authStore: ReturnType<typeof useAuthStore>
} | null = null
constructor() {
@@ -109,12 +109,11 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
}
}
if (stores.firebaseAuthStore.currentUser) {
if (stores.authStore.currentUser) {
return {
customerId:
stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
customerId: stores.authStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
customerEmail:
stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
stores.authStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
}
}
@@ -135,7 +134,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
private resolveAuthStores(): {
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
authStore: ReturnType<typeof useAuthStore>
} | null {
if (this.stores) {
return this.stores
@@ -144,7 +143,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
try {
const stores = {
apiKeyAuthStore: useApiKeyAuthStore(),
firebaseAuthStore: useFirebaseAuthStore()
authStore: useAuthStore()
}
this.stores = stores
return stores

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
@@ -288,7 +288,7 @@ const workspaceApiClient = axios.create({
})
async function getAuthHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
const authHeader = await useAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
@@ -300,7 +300,7 @@ async function getAuthHeaderOrThrow() {
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getFirebaseAuthHeader()
const authHeader = await useAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),

View File

@@ -8,8 +8,8 @@ import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
const mockIsInitialized = ref(false)
const mockCurrentUser = ref<object | null>(null)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
isInitialized: mockIsInitialized,
currentUser: mockCurrentUser
})

View File

@@ -27,7 +27,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
@@ -38,7 +38,7 @@ const subscriptionDialog = useSubscriptionDialog()
async function initialize(): Promise<void> {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const { isInitialized, currentUser } = storeToRefs(authStore)
try {

View File

@@ -30,7 +30,7 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-[11px] font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
>
-20%
</div>
@@ -58,7 +58,7 @@
</span>
<div
v-if="tier.isPopular"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-[11px] font-bold tracking-tight text-base-background uppercase"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-2xs font-bold tracking-tight text-base-background uppercase"
>
{{ t('subscription.mostPopular') }}
</div>

View File

@@ -100,6 +100,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
@@ -218,8 +219,8 @@ async function handleAddCreditCard() {
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`
)
if (!response) return
@@ -272,8 +273,8 @@ async function handleConfirmTransition() {
if (!planSlug) return
const response = await subscribe(
planSlug,
'https://www.comfy.org/payment/success',
'https://www.comfy.org/payment/failed'
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`
)
if (!response) return

View File

@@ -48,7 +48,7 @@
</span>
<span
v-if="resolveTierLabel(workspace)"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ resolveTierLabel(workspace) }}
</span>

View File

@@ -300,6 +300,25 @@ describe('TeamWorkspacesDialogContent', () => {
expect(mockCreateWorkspace).not.toHaveBeenCalled()
})
it('resets loading state after createWorkspace fails', async () => {
mockCreateWorkspace.mockRejectedValue(new Error('Limit reached'))
const wrapper = mountComponent()
await typeAndCreate(wrapper, 'New Team')
expect(findCreateButton(wrapper).props('loading')).toBe(false)
})
it('resets loading state after onConfirm fails', async () => {
mockCreateWorkspace.mockResolvedValue({ id: 'new-ws' })
const onConfirm = vi.fn().mockRejectedValue(new Error('Setup failed'))
const wrapper = mountComponent({ onConfirm })
await typeAndCreate(wrapper, 'New Team')
expect(findCreateButton(wrapper).props('loading')).toBe(false)
})
})
describe('close button', () => {

View File

@@ -62,7 +62,7 @@
</span>
<span
v-if="tierLabels.get(workspace.id)"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ tierLabels.get(workspace.id) }}
</span>
@@ -201,28 +201,30 @@ async function handleSwitch(workspaceId: string) {
async function onCreate() {
if (!isValidName.value || loading.value) return
loading.value = true
const name = workspaceName.value.trim()
try {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
const name = workspaceName.value.trim()
try {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
return
}
try {
await onConfirm?.(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
}
dialogStore.closeDialog({ key: DIALOG_KEY })
} finally {
loading.value = false
return
}
try {
await onConfirm?.(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('teamWorkspacesDialog.confirmCallbackFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
}
dialogStore.closeDialog({ key: DIALOG_KEY })
loading.value = false
}
</script>

View File

@@ -153,7 +153,7 @@
</span>
<span
v-if="uiConfig.showRoleBadge"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ $t('workspaceSwitcher.roleOwner') }}
</span>
@@ -202,7 +202,7 @@
</span>
<span
v-if="uiConfig.showRoleBadge"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ getRoleBadgeLabel(member.role) }}
</span>

View File

@@ -10,8 +10,8 @@ import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
const mockGetIdToken = vi.fn()
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken
})
}))

View File

@@ -9,7 +9,7 @@ import {
WORKSPACE_STORAGE_KEYS
} from '@/platform/workspace/workspaceConstants'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -181,8 +181,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
error.value = null
try {
const firebaseAuthStore = useFirebaseAuthStore()
const firebaseToken = await firebaseAuthStore.getIdToken()
const authStore = useAuthStore()
const firebaseToken = await authStore.getIdToken()
if (!firebaseToken) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.notAuthenticated'),

View File

@@ -110,4 +110,19 @@ describe('FormDropdownMenu', () => {
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
expect(virtualGrid.props('maxColumns')).toBe(1)
})
it('has data-capture-wheel="true" on the root element', () => {
const wrapper = mount(FormDropdownMenu, {
props: defaultProps,
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
expect(wrapper.attributes('data-capture-wheel')).toBe('true')
})
})

View File

@@ -98,6 +98,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"

View File

@@ -103,6 +103,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
v-model="searchQuery"
autofocus
:class="
cn(
actionButtonStyle,

View File

@@ -38,9 +38,9 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/stores/firebaseAuthStore', async () => {
vi.mock('@/stores/authStore', async () => {
return {
useFirebaseAuthStore: vi.fn(() => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
}))
}

View File

@@ -5,7 +5,7 @@ import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -23,7 +23,7 @@ interface CacheEntry<T> {
async function getAuthHeaders() {
if (isCloud) {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })

View File

@@ -11,7 +11,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
@@ -140,7 +140,7 @@ if (isCloud) {
}
// Global authentication guard
router.beforeEach(async (to, _from, next) => {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
// Wait for Firebase auth to initialize
// Timeout after 16 seconds

View File

@@ -60,7 +60,7 @@ import type {
JobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
@@ -332,7 +332,7 @@ export class ComfyApi extends EventTarget {
/**
* Cache Firebase auth store composable function.
*/
private authStoreComposable?: typeof useFirebaseAuthStore
private authStoreComposable?: typeof useAuthStore
reportedUnknownMessageTypes = new Set<string>()
@@ -399,8 +399,8 @@ export class ComfyApi extends EventTarget {
private async getAuthStore() {
if (isCloud) {
if (!this.authStoreComposable) {
const module = await import('@/stores/firebaseAuthStore')
this.authStoreComposable = module.useFirebaseAuthStore
const module = await import('@/stores/authStore')
this.authStoreComposable = module.useAuthStore
}
return this.authStoreComposable()

View File

@@ -67,7 +67,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
@@ -1594,7 +1594,7 @@ export class ComfyApp {
executionErrorStore.clearAllErrors()
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
const comfyOrgAuthToken = await useFirebaseAuthStore().getAuthToken()
const comfyOrgAuthToken = await useAuthStore().getAuthToken()
const comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
try {

View File

@@ -11,7 +11,7 @@ const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
}))
const mockFirebaseAuthStore = vi.hoisted(() => ({
const mockAuthStore = vi.hoisted(() => ({
getAuthHeader: vi.fn()
}))
@@ -27,8 +27,8 @@ vi.mock('axios', () => ({
}
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/i18n', () => ({
@@ -81,7 +81,7 @@ describe('useCustomerEventsService', () => {
vi.clearAllMocks()
// Setup default mocks
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
mockAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
mockI18n.d.mockImplementation((date, options) => {
// Mock i18n date formatting
if (options?.month === 'short') {
@@ -118,7 +118,7 @@ describe('useCustomerEventsService', () => {
limit: 10
})
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalled()
expect(mockAuthStore.getAuthHeader).toHaveBeenCalled()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
params: { page: 1, limit: 10 },
headers: mockAuthHeaders
@@ -141,7 +141,7 @@ describe('useCustomerEventsService', () => {
})
it('should return null when auth headers are missing', async () => {
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
mockAuthStore.getAuthHeader.mockResolvedValue(null)
const result = await service.getMyEvents()

View File

@@ -4,7 +4,7 @@ import { ref, watch } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { d } from '@/i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -180,7 +180,7 @@ export const useCustomerEventsService = () => {
}
// Get auth headers
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
const authHeaders = await useAuthStore().getAuthHeader()
if (!authHeaders) {
error.value = 'Authentication header is missing'
return null

View File

@@ -5,7 +5,7 @@ import { computed, ref, watch } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { ApiKeyAuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -15,7 +15,7 @@ type ComfyApiUser =
const STORAGE_KEY = 'comfy_api_key'
export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const firebaseAuthStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const apiKey = useLocalStorage<string | null>(STORAGE_KEY, null)
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
@@ -24,7 +24,7 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const isAuthenticated = computed(() => !!currentUser.value)
const initializeUserFromApiKey = async () => {
const createCustomerResponse = await firebaseAuthStore
const createCustomerResponse = await authStore
.createCustomer()
.catch((err) => {
console.error(err)

View File

@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
// Hoisted mocks for dynamic imports
@@ -122,8 +122,8 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
})
}))
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
describe('useAuthStore', () => {
let store: ReturnType<typeof useAuthStore>
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
@@ -182,7 +182,7 @@ describe('useFirebaseAuthStore', () => {
// Initialize Pinia
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirebaseAuthStore()
store = useAuthStore()
// Reset and set up getIdToken mock
mockUser.getIdToken.mockReset()
@@ -210,8 +210,8 @@ describe('useFirebaseAuthStore', () => {
)
setActivePinia(createTestingPinia({ stubActions: false }))
const storeModule = await import('@/stores/firebaseAuthStore')
store = storeModule.useFirebaseAuthStore()
const storeModule = await import('@/stores/authStore')
store = storeModule.useAuthStore()
})
it("should not increment tokenRefreshTrigger on the user's first ID token event", () => {

View File

@@ -49,14 +49,14 @@ export type BillingPortalTargetTier = NonNullable<
>['application/json']
>['target_tier']
export class FirebaseAuthStoreError extends Error {
export class AuthStoreError extends Error {
constructor(message: string) {
super(message)
this.name = 'FirebaseAuthStoreError'
this.name = 'AuthStoreError'
}
}
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
export const useAuthStore = defineStore('auth', () => {
const { flags } = useFeatureFlags()
// State
@@ -241,9 +241,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
try {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/balance'), {
@@ -259,7 +257,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return null
}
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
@@ -279,7 +277,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(buildApiUrl('/customers'), {
@@ -290,7 +288,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
})
if (!createCustomerRes.ok) {
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: createCustomerRes.statusText
})
@@ -300,7 +298,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const createCustomerResJson: CreateCustomerResponse =
await createCustomerRes.json()
if (!createCustomerResJson?.id) {
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: 'No customer ID returned'
})
@@ -431,7 +429,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
/** Update password for current user */
const _updatePassword = async (newPassword: string): Promise<void> => {
if (!currentUser.value) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await updatePassword(currentUser.value, newPassword)
}
@@ -441,7 +439,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
): Promise<CreditPurchaseResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
@@ -461,7 +459,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
@@ -481,7 +479,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/billing'), {
@@ -497,7 +495,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})

View File

@@ -50,12 +50,12 @@ vi.mock('@/stores/userStore', () => ({
}))
}))
const mockIsFirebaseInitialized = ref(false)
const mockIsFirebaseAuthenticated = ref(false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
isInitialized: mockIsFirebaseInitialized,
isAuthenticated: mockIsFirebaseAuthenticated
const mockIsAuthInitialized = ref(false)
const mockIsAuthAuthenticated = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
isInitialized: mockIsAuthInitialized,
isAuthenticated: mockIsAuthAuthenticated
}))
}))
@@ -67,8 +67,8 @@ vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
describe('bootstrapStore', () => {
beforeEach(() => {
mockIsSettingsReady.value = false
mockIsFirebaseInitialized.value = false
mockIsFirebaseAuthenticated.value = false
mockIsAuthInitialized.value = false
mockIsAuthAuthenticated.value = false
mockNeedsLogin.value = false
mockDistributionTypes.isCloud = false
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -107,14 +107,14 @@ describe('bootstrapStore', () => {
expect(settingStore.isReady).toBe(false)
// Firebase initialized but user not yet authenticated
mockIsFirebaseInitialized.value = true
mockIsAuthInitialized.value = true
await nextTick()
expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)
// User authenticates (e.g. signs in on login page)
mockIsFirebaseAuthenticated.value = true
mockIsAuthAuthenticated.value = true
await bootstrapPromise
await vi.waitFor(() => {

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