Compare commits

..

7 Commits

Author SHA1 Message Date
Christian Byrne
5cd24e562b Merge branch 'main' into perf/batch-progress-events 2026-03-26 21:09:49 -07:00
bymyself
95a1042854 refactor: extract createRafCoalescer utility for last-write-wins RAF batching
Replace manual _pending* variables + createRafBatch + null-check
callbacks with a typed createRafCoalescer<T> that encapsulates the
coalescing pattern. Extract cancelPendingProgressUpdates() helper
to deduplicate the 3 cancel sites in executionStore.
2026-03-25 12:45:06 -07:00
GitHub Action
3a4ab844db [automated] Apply ESLint and Oxfmt fixes 2026-03-25 12:45:06 -07:00
bymyself
31c51d9a2e test: cover pending RAF discard when execution completes
Adds tests verifying that pending progress and progress_state RAFs
are cancelled when execution ends via success, error, or interruption.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923353907
2026-03-25 12:45:06 -07:00
bymyself
c77f461817 fix: cancel pending RAFs in resetExecutionState and handleExecuting
Prevents a race condition where:
1. A progress WebSocket event arrives and schedules a RAF
2. An execution-end event fires and clears all state
3. The pending RAF callback fires and writes stale data back

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923324838
2026-03-25 12:45:06 -07:00
bymyself
72b1f464cb fix: use createRafBatch utility instead of manual RAF management
Replaces four raw `let` variables and manual requestAnimationFrame/
cancelAnimationFrame calls with two `createRafBatch` instances from
the existing `src/utils/rafBatch.ts` utility.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9303#discussion_r2923338919
2026-03-25 12:45:06 -07:00
bymyself
c4919523c5 fix: RAF-batch WebSocket progress events to reduce reactive update storms
Amp-Thread-ID: https://ampcode.com/threads/T-019ca43d-b7a5-759f-b88f-1319faac8a01
2026-03-25 12:45:06 -07:00
100 changed files with 867 additions and 2206 deletions

View File

@@ -1,48 +0,0 @@
{
"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,12 +19,10 @@ 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'
@@ -57,7 +55,6 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -81,11 +78,6 @@ 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
@@ -200,7 +192,6 @@ 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 */
@@ -247,7 +238,6 @@ 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

@@ -1,5 +1,4 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { TestIds } from '../selectors'
@@ -169,201 +168,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')
}
// --- Tab navigation ---
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
get importedTab() {
return this.page.getByRole('tab', { name: 'Imported' })
}
// --- Empty state ---
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
)
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
// --- Search & filter ---
get searchInput() {
return this.page.getByPlaceholder('Search Assets...')
}
get settingsButton() {
return this.page.getByRole('button', { name: 'View settings' })
}
// --- View mode ---
get listViewOption() {
return this.page.getByText('List view')
}
get gridViewOption() {
return this.page.getByText('Grid view')
}
// --- Sort options (cloud-only, shown inside settings popover) ---
get sortNewestFirst() {
return this.page.getByText('Newest first')
}
get sortOldestFirst() {
return this.page.getByText('Oldest first')
}
// --- Asset cards ---
get assetCards() {
return this.page.locator('[role="button"][data-selected]')
}
getAssetCardByName(name: string) {
return this.page.locator('[role="button"][data-selected]', {
hasText: name
})
}
get selectedCards() {
return this.page.locator('[data-selected="true"]')
}
// --- List view items ---
get listViewItems() {
return this.page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
}
// --- Selection footer ---
get selectionFooter() {
return this.page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
}
get selectionCountButton() {
return this.page.getByText(/Assets Selected: \d+/)
}
get deselectAllButton() {
return this.page.getByText('Deselect all')
}
get deleteSelectedButton() {
return this.page
.getByTestId('assets-delete-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
}
get downloadSelectedButton() {
return this.page
.getByTestId('assets-download-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
}
// --- Context menu ---
contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label)
}
// --- Folder view ---
get backToAssetsButton() {
return this.page.getByText('Back to all assets')
}
// --- Loading ---
get skeletonLoaders() {
return this.page.locator('.sidebar-content-container .animate-pulse')
}
// --- Helpers ---
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
/** Dismiss all visible toast notifications by clicking their close buttons. */
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
}
// Wait for toast containers to animate out after close
await this.page
.locator('.p-toast')
.first()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
}
async switchToImported() {
await this.dismissToasts()
// Use evaluate click because toast overlay can intercept force clicks
// during fade-out animation (browser hit-test still routes to overlay)
await this.importedTab.evaluate((el: HTMLElement) => el.click())
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async switchToGenerated() {
await this.dismissToasts()
await this.generatedTab.evaluate((el: HTMLElement) => el.click())
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async openSettingsMenu() {
await this.dismissToasts()
await this.settingsButton.click({ force: true })
// Wait for popover content to render
await this.listViewOption
.or(this.gridViewOption)
.first()
.waitFor({ state: 'visible', timeout: 3000 })
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async waitForAssets(count?: number) {
if (count !== undefined) {
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
} else {
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
}
}
}

View File

@@ -1,204 +0,0 @@
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(?:\?.*)?$/
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now() / 1000
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
priority: 0,
...overrides
}
}
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now() / 1000
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60,
execution_start_time: now - i * 60,
execution_end_time: now - i * 60 + 5 + i,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
return Array.from(
{ length: count },
(_, i) =>
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
)
}
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,12 +20,7 @@ export const TestIds = {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-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'
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
},
dialogs: {
settings: 'settings-dialog',

View File

@@ -1,92 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,667 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import {
createMockJob,
createMockJobs
} from '../../fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const SAMPLE_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-beta',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2003,
preview_output: {
filename: 'portrait.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gamma',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3020,
preview_output: {
filename: 'abstract_art.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
]
const SAMPLE_IMPORTED_FILES = [
'reference_photo.png',
'background.jpg',
'audio_clip.wav'
]
// ==========================================================================
// 1. Empty states
// ==========================================================================
test.describe('Assets sidebar - empty states', () => {
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 tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.assetCards).toHaveCount(0)
})
})
// ==========================================================================
// 2. Tab navigation
// ==========================================================================
test.describe('Assets sidebar - tab navigation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Generated tab is active by default', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
})
test('Can switch between Generated and Imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
})
// ==========================================================================
// 3. Asset display - grid view
// ==========================================================================
test.describe('Assets sidebar - grid view display', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Displays generated assets as cards in grid view', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test('Displays imported files when switching to Imported tab', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
// Imported tab should show the mocked files
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})
// ==========================================================================
// 4. View mode toggle (grid <-> list)
// ==========================================================================
test.describe('Assets sidebar - view mode toggle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
})
test('Can switch back to grid view', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
// Switch back to grid by setting localStorage and refreshing the panel
await comfyPage.page.evaluate(() => {
localStorage.setItem(
'Comfy.Assets.Sidebar.ViewMode',
JSON.stringify('grid')
)
})
// Close and reopen sidebar to pick up the localStorage change
await tab.close()
await tab.open()
await tab.waitForAssets()
// Grid cards (with data-selected attribute) should be visible again
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
})
})
// ==========================================================================
// 5. Search functionality
// ==========================================================================
test.describe('Assets sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Search input is visible', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.searchInput).toBeVisible()
})
test('Filtering assets by search query reduces displayed count', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect(async () => {
const filteredCount = await tab.assetCards.count()
expect(filteredCount).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
})
test('Clearing search restores all assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect(async () => {
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
await tab.searchInput.fill('')
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
test('Search with no matches shows empty state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.searchInput.fill('nonexistent_file_xyz')
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
})
})
// ==========================================================================
// 6. Asset selection
// ==========================================================================
test.describe('Assets sidebar - selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
test('Selection shows footer with count and actions', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
})
test('Deselect all clears selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
})
})
// ==========================================================================
// 7. Context menu
// ==========================================================================
test.describe('Assets sidebar - context menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible({ timeout: 3000 })
})
test('Context menu contains Download action for output asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Download')).toBeVisible()
})
test('Context menu contains Inspect action for image assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
})
test('Context menu contains Delete action for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Delete')).toBeVisible()
})
test('Context menu contains Copy job ID for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
})
test('Context menu contains workflow actions for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page.waitForTimeout(200)
await expect(
tab.contextMenuItem('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
})
test('Bulk context menu shows when multiple assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
// Multi-select: click first, then Ctrl/Cmd+click second
await cards.first().click({ force: true })
await cards.nth(1).click({ modifiers: ['ControlOrMeta'], force: true })
// Verify multi-selection took effect before right-clicking
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
// Right-click on a selected card via dispatchEvent to ensure contextmenu fires
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible({ timeout: 3000 })
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
// ==========================================================================
// 8. Bulk actions (footer)
// ==========================================================================
test.describe('Assets sidebar - bulk actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Footer shows download button when assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
// Download button in footer should be visible
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Footer shows delete button when output assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
// Delete button in footer should be visible
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Selection count displays correct number', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select two assets
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
await cards.first().click()
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
const text = await tab.selectionCountButton.textContent()
expect(text).toMatch(/Assets Selected: \d+/)
})
})
// ==========================================================================
// 9. Pagination
// ==========================================================================
test.describe('Assets sidebar - pagination', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Initially loads a batch of assets with has_more pagination', async ({
comfyPage
}) => {
// Create a large set of jobs to trigger pagination
const manyJobs = createMockJobs(30)
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Should load at least the first batch
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})
// ==========================================================================
// 10. Settings menu visibility
// ==========================================================================
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})

View File

@@ -1,138 +0,0 @@
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

@@ -25,9 +25,6 @@
@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,15 +7,17 @@
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, 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()
@@ -127,5 +129,26 @@ 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/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))

View File

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

View File

@@ -32,8 +32,8 @@ const mockBalance = vi.hoisted(() => ({
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: 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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
@@ -167,7 +167,7 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useAuthActions()
const authActions = useFirebaseAuthActions()
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 = useAuthActions()
const authActions = useFirebaseAuthActions()
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
const authActions = useAuthActions()
const authActions = useFirebaseAuthActions()
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
@@ -133,8 +133,8 @@ interface CreditHistoryItemData {
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
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/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: 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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
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/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useAuthStore()
const authActions = useAuthActions()
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
@@ -127,6 +127,6 @@ const handleForgotPassword = async (
document.getElementById(emailInputId)?.focus?.()
return
}
await authActions.sendPasswordReset(email)
await firebaseAuthActions.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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import PasswordFields from './PasswordFields.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const emit = defineEmits<{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-2xs/normal font-normal text-muted-foreground"
class="m-0 text-[11px] leading-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-2xs text-muted-foreground"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
>
<span
v-if="

View File

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

View File

@@ -143,16 +143,11 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
size="icon"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<Button size="icon" @click="handleDownloadSelected">
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
@@ -161,17 +156,12 @@
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="secondary"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<Button variant="secondary" @click="handleDownloadSelected">
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>

View File

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

View File

@@ -61,10 +61,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
// Mock the useFirebaseAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
@@ -77,7 +77,7 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
// Mock the authStore with hoisted state for per-test manipulation
// Mock the firebaseAuthStore 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/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const emit = defineEmits<{
close: []
@@ -178,8 +178,8 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {

View File

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

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 { useAuthStore } from '@/stores/authStore'
import type { BillingPortalTargetTier } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
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 useAuthActions = () => {
const authStore = useAuthStore()
export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore()
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()

View File

@@ -70,8 +70,8 @@ vi.mock(
})
)
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
BalanceInfo,
@@ -33,7 +33,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const authStore = useAuthStore()
const firebaseAuthStore = useFirebaseAuthStore()
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: (authStore.balance?.amount_micros ?? 0) > 0
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = authStore.balance
const legacyBalance = firebaseAuthStore.balance
if (!legacyBalance) return null
return {
@@ -118,7 +118,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading.value = true
error.value = null
try {
await authStore.fetchBalance()
await firebaseAuthStore.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/authStore', () => ({
useAuthStore: vi.fn(() => ({}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
@@ -123,8 +123,8 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({

View File

@@ -1,5 +1,5 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 authActions = useAuthActions()
const firebaseAuthActions = useFirebaseAuthActions()
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 authActions.logout()
await firebaseAuthActions.logout()
}
},
{

View File

@@ -67,14 +67,7 @@ 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,7 +141,6 @@ 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,
@@ -280,8 +279,7 @@ const formattedDuration = computed(() => {
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
if (fileKind.value === 'image' && imageDimensions.value) {
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
/**
* Session cookie management for cloud authentication.
@@ -21,7 +21,7 @@ export const useSessionCookie = () => {
const { flags } = useFeatureFlags()
try {
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
let authHeader: Record<string, string>

View File

@@ -1,135 +0,0 @@
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

@@ -1,57 +0,0 @@
<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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
interface Props {
errorMessage?: string
@@ -83,7 +83,7 @@ interface Props {
defineProps<Props>()
const router = useRouter()
const { logout } = useAuthActions()
const { logout } = useFirebaseAuthActions()
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
const { t } = useI18n()
const router = useRouter()
const authActions = useAuthActions()
const authActions = useFirebaseAuthActions()
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 = useAuthActions()
const authActions = useFirebaseAuthActions()
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-2xs 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-[10px] 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 = useAuthActions()
const authActions = useFirebaseAuthActions()
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/useAuthActions', () => ({
useAuthActions: () => authActionMocks
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 } = useAuthActions()
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
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/useAuthActions', () => ({
useAuthActions: () => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal,
reportError: mockReportError
})
@@ -56,13 +56,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () =>
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () =>
reactive({
getAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
}),
AuthStoreError: class extends Error {}
FirebaseAuthStoreError: 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-2xs font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-[11px] 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-2xs font-bold tracking-tight text-base-background uppercase"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-[11px] 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
@@ -365,8 +365,8 @@ const {
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()
const { userId } = storeToRefs(useFirebaseAuthStore())
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)

View File

@@ -82,8 +82,8 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 = useAuthActions()
const authActions = useFirebaseAuthActions()
const { t, n } = useI18n()
const {

View File

@@ -65,8 +65,8 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
reportError: mockReportError,
accessBillingPortal: mockAccessBillingPortal
}))
@@ -106,14 +106,14 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
get userId() {
return mockUserId.value
}
})),
AuthStoreError: class extends Error {}
FirebaseAuthStoreError: 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
@@ -10,7 +10,10 @@ 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 { AuthStoreError, useAuthStore } from '@/stores/authStore'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -34,11 +37,11 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useAuthActions()
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { showSubscriptionRequiredDialog } = useDialogService()
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const firebaseAuthStore = useFirebaseAuthStore()
const { getAuthHeader } = firebaseAuthStore
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -191,7 +194,7 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
@@ -206,7 +209,7 @@ function useSubscriptionInternal() {
if (!response.ok) {
const errorData = await response.json()
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
@@ -245,7 +248,9 @@ function useSubscriptionInternal() {
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const checkoutAttribution = await getCheckoutAttributionForCloud()
@@ -263,7 +268,7 @@ function useSubscriptionInternal() {
if (!response.ok) {
const errorData = await response.json()
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
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/useAuthActions', () => ({
useAuthActions: () => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))

View File

@@ -1,7 +1,7 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 = useAuthActions()
const authActions = useFirebaseAuthActions()
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/authStore', () => ({
useAuthStore: vi.fn(() =>
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() =>
reactive({
getAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
})
),
AuthStoreError: class extends Error {}
FirebaseAuthStoreError: class extends Error {}
}))
vi.mock('@/platform/distribution/types', () => ({

View File

@@ -4,7 +4,10 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'
@@ -48,13 +51,13 @@ export async function performSubscriptionCheckout(
): Promise<void> {
if (!isCloud) return
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const firebaseAuthStore = useFirebaseAuthStore()
const { userId } = storeToRefs(firebaseAuthStore)
const telemetry = useTelemetry()
const authHeader = await authStore.getAuthHeader()
const authHeader = await firebaseAuthStore.getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
@@ -94,7 +97,7 @@ export async function performSubscriptionCheckout(
}
}
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 = useAuthActions()
const authActions = useFirebaseAuthActions()
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 MockAuthUser = {
type MockFirebaseUser = {
uid: string
email?: string | null
} | null
@@ -14,19 +14,19 @@ type MockAuthUser = {
const {
mockCaptureCheckoutAttributionFromSearch,
mockUseApiKeyAuthStore,
mockUseAuthStore,
mockUseFirebaseAuthStore,
mockApiKeyAuthStore,
mockAuthStore
mockFirebaseAuthStore
} = vi.hoisted(() => ({
mockCaptureCheckoutAttributionFromSearch: vi.fn(),
mockUseApiKeyAuthStore: vi.fn(),
mockUseAuthStore: vi.fn(),
mockUseFirebaseAuthStore: vi.fn(),
mockApiKeyAuthStore: {
isAuthenticated: false,
currentUser: null as MockApiKeyUser
},
mockAuthStore: {
currentUser: null as MockAuthUser
mockFirebaseAuthStore: {
currentUser: null as MockFirebaseUser
}
}))
@@ -38,8 +38,8 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: mockUseApiKeyAuthStore
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: mockUseAuthStore
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: mockUseFirebaseAuthStore
}))
import { ImpactTelemetryProvider } from './ImpactTelemetryProvider'
@@ -64,14 +64,14 @@ describe('ImpactTelemetryProvider', () => {
beforeEach(() => {
mockCaptureCheckoutAttributionFromSearch.mockReset()
mockUseApiKeyAuthStore.mockReset()
mockUseAuthStore.mockReset()
mockUseFirebaseAuthStore.mockReset()
mockApiKeyAuthStore.isAuthenticated = false
mockApiKeyAuthStore.currentUser = null
mockAuthStore.currentUser = null
mockFirebaseAuthStore.currentUser = null
vi.restoreAllMocks()
vi.unstubAllGlobals()
mockUseApiKeyAuthStore.mockReturnValue(mockApiKeyAuthStore)
mockUseAuthStore.mockReturnValue(mockAuthStore)
mockUseFirebaseAuthStore.mockReturnValue(mockFirebaseAuthStore)
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 () => {
mockAuthStore.currentUser = {
mockFirebaseAuthStore.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 () => {
mockAuthStore.currentUser = {
mockFirebaseAuthStore.currentUser = {
uid: 'user-123',
email: 'user@example.com'
}
@@ -189,7 +189,7 @@ describe('ImpactTelemetryProvider', () => {
id: 'api-key-user-123',
email: 'apikey@example.com'
}
mockAuthStore.currentUser = {
mockFirebaseAuthStore.currentUser = {
uid: 'firebase-user-123',
email: 'firebase@example.com'
}
@@ -228,7 +228,7 @@ describe('ImpactTelemetryProvider', () => {
id: 'api-key-user-123',
email: 'apikey@example.com'
}
mockAuthStore.currentUser = null
mockFirebaseAuthStore.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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { PageViewMetadata, TelemetryProvider } from '../../types'
@@ -17,7 +17,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
private initialized = false
private stores: {
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
authStore: ReturnType<typeof useAuthStore>
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
} | null = null
constructor() {
@@ -109,11 +109,12 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
}
}
if (stores.authStore.currentUser) {
if (stores.firebaseAuthStore.currentUser) {
return {
customerId: stores.authStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
customerId:
stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
customerEmail:
stores.authStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
}
}
@@ -134,7 +135,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
private resolveAuthStores(): {
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
authStore: ReturnType<typeof useAuthStore>
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
} | null {
if (this.stores) {
return this.stores
@@ -143,7 +144,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
try {
const stores = {
apiKeyAuthStore: useApiKeyAuthStore(),
authStore: useAuthStore()
firebaseAuthStore: useFirebaseAuthStore()
}
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
@@ -288,7 +288,7 @@ const workspaceApiClient = axios.create({
})
async function getAuthHeaderOrThrow() {
const authHeader = await useAuthStore().getAuthHeader()
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
@@ -300,7 +300,7 @@ async function getAuthHeaderOrThrow() {
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useAuthStore().getFirebaseAuthHeader()
const authHeader = await useFirebaseAuthStore().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/authStore', () => ({
useAuthStore: () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 = useAuthStore()
const authStore = useFirebaseAuthStore()
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-2xs font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-[11px] 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-2xs font-bold tracking-tight text-base-background uppercase"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-[11px] font-bold tracking-tight text-base-background uppercase"
>
{{ t('subscription.mostPopular') }}
</div>

View File

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

View File

@@ -300,25 +300,6 @@ 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-2xs font-bold text-base-background uppercase"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
>
{{ tierLabels.get(workspace.id) }}
</span>
@@ -201,30 +201,28 @@ async function handleSwitch(workspaceId: string) {
async function onCreate() {
if (!isValidName.value || loading.value) return
loading.value = true
const name = workspaceName.value.trim()
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 {
await workspaceStore.createWorkspace(name)
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
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-2xs font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] 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-2xs font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] 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/authStore', () => ({
useAuthStore: () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getIdToken: mockGetIdToken
})
}))

View File

@@ -9,7 +9,7 @@ import {
WORKSPACE_STORAGE_KEYS
} from '@/platform/workspace/workspaceConstants'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 authStore = useAuthStore()
const firebaseToken = await authStore.getIdToken()
const firebaseAuthStore = useFirebaseAuthStore()
const firebaseToken = await firebaseAuthStore.getIdToken()
if (!firebaseToken) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.notAuthenticated'),

View File

@@ -110,19 +110,4 @@ 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,7 +98,6 @@ 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,7 +103,6 @@ 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/authStore', async () => {
vi.mock('@/stores/firebaseAuthStore', async () => {
return {
useAuthStore: vi.fn(() => ({
useFirebaseAuthStore: 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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -23,7 +23,7 @@ interface CacheEntry<T> {
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 = useAuthStore()
const authStore = useFirebaseAuthStore()
// 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 { useAuthStore } from '@/stores/authStore'
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 useAuthStore
private authStoreComposable?: typeof useFirebaseAuthStore
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/authStore')
this.authStoreComposable = module.useAuthStore
const module = await import('@/stores/firebaseAuthStore')
this.authStoreComposable = module.useFirebaseAuthStore
}
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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 useAuthStore().getAuthToken()
const comfyOrgAuthToken = await useFirebaseAuthStore().getAuthToken()
const comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
try {

View File

@@ -11,7 +11,7 @@ const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
}))
const mockAuthStore = vi.hoisted(() => ({
const mockFirebaseAuthStore = vi.hoisted(() => ({
getAuthHeader: vi.fn()
}))
@@ -27,8 +27,8 @@ vi.mock('axios', () => ({
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
}))
vi.mock('@/i18n', () => ({
@@ -81,7 +81,7 @@ describe('useCustomerEventsService', () => {
vi.clearAllMocks()
// Setup default mocks
mockAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
mockFirebaseAuthStore.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(mockAuthStore.getAuthHeader).toHaveBeenCalled()
expect(mockFirebaseAuthStore.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 () => {
mockAuthStore.getAuthHeader.mockResolvedValue(null)
mockFirebaseAuthStore.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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 useAuthStore().getAuthHeader()
const authHeaders = await useFirebaseAuthStore().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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
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 authStore = useAuthStore()
const firebaseAuthStore = useFirebaseAuthStore()
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 authStore
const createCustomerResponse = await firebaseAuthStore
.createCustomer()
.catch((err) => {
console.error(err)

View File

@@ -50,12 +50,12 @@ vi.mock('@/stores/userStore', () => ({
}))
}))
const mockIsAuthInitialized = ref(false)
const mockIsAuthAuthenticated = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
isInitialized: mockIsAuthInitialized,
isAuthenticated: mockIsAuthAuthenticated
const mockIsFirebaseInitialized = ref(false)
const mockIsFirebaseAuthenticated = ref(false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
isInitialized: mockIsFirebaseInitialized,
isAuthenticated: mockIsFirebaseAuthenticated
}))
}))
@@ -67,8 +67,8 @@ vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
describe('bootstrapStore', () => {
beforeEach(() => {
mockIsSettingsReady.value = false
mockIsAuthInitialized.value = false
mockIsAuthAuthenticated.value = false
mockIsFirebaseInitialized.value = false
mockIsFirebaseAuthenticated.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
mockIsAuthInitialized.value = true
mockIsFirebaseInitialized.value = true
await nextTick()
expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)
// User authenticates (e.g. signs in on login page)
mockIsAuthAuthenticated.value = true
mockIsFirebaseAuthenticated.value = true
await bootstrapPromise
await vi.waitFor(() => {

View File

@@ -5,7 +5,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useUserStore } from '@/stores/userStore'
export const useBootstrapStore = defineStore('bootstrap', () => {
@@ -37,7 +37,9 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
async function startStoreBootstrap() {
if (isCloud) {
const { isInitialized, isAuthenticated } = storeToRefs(useAuthStore())
const { isInitialized, isAuthenticated } = storeToRefs(
useFirebaseAuthStore()
)
await until(isInitialized).toBe(true)
await until(isAuthenticated).toBe(true)
}

View File

@@ -1,6 +1,7 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -16,6 +17,27 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
clientId: null,
apiURL: vi.fn((path: string) => path)
}
}))
vi.mock('@/stores/imagePreviewStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
})
}))
vi.mock('@/stores/jobPreviewStore', () => ({
useJobPreviewStore: () => ({
clearPreview: vi.fn()
})
}))
// Mock the workflowStore
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
@@ -331,9 +353,12 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
handler(
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
)
// Flush the RAF so the batched update is applied immediately
vi.advanceTimersByTime(16)
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -341,6 +366,10 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
it('should retain entries below the limit', () => {
for (let i = 0; i < 5; i++) {
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
@@ -695,3 +724,309 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})
describe('useExecutionStore - RAF batching', () => {
let store: ReturnType<typeof useExecutionStore>
function getRegisteredHandler(eventName: string) {
const calls = vi.mocked(api.addEventListener).mock.calls
const call = calls.find(([name]) => name === eventName)
return call?.[1] as (e: CustomEvent) => void
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
describe('handleProgress', () => {
function makeProgressEvent(value: number, max: number): CustomEvent {
return new CustomEvent('progress', {
detail: { value, max, prompt_id: 'job-1', node: '1' }
})
}
it('batches multiple progress events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
handler(makeProgressEvent(5, 10))
handler(makeProgressEvent(9, 10))
expect(store._executingNodeProgress).toBeNull()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual({
value: 9,
max: 10,
prompt_id: 'job-1',
node: '1'
})
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(3, 10))
expect(store._executingNodeProgress).toBeNull()
})
it('allows a new batch after the previous RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 1 })
)
handler(makeProgressEvent(7, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 7 })
)
})
})
describe('handleProgressState', () => {
function makeProgressStateEvent(
nodeId: string,
state: string,
value = 0,
max = 10
): CustomEvent {
return new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
[nodeId]: {
value,
max,
state,
node_id: nodeId,
prompt_id: 'job-1',
display_node_id: nodeId
}
}
}
})
}
it('batches multiple progress_state events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running', 1))
handler(makeProgressStateEvent('1', 'running', 5))
handler(makeProgressStateEvent('1', 'running', 9))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
vi.advanceTimersByTime(16)
expect(store.nodeProgressStates['1']).toEqual(
expect.objectContaining({ value: 9, state: 'running' })
)
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running'))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
describe('pending RAF is discarded when execution completes', () => {
it('discards pending progress RAF on execution_success', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress_state RAF on execution_success', () => {
const progressStateHandler = getRegisteredHandler('progress_state')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressStateHandler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
it('discards pending progress RAF on execution_error', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const errorHandler = getRegisteredHandler('execution_error')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
errorHandler(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
exception_message: 'error',
exception_type: 'RuntimeError',
traceback: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress RAF on execution_interrupted', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const interruptedHandler = getRegisteredHandler('execution_interrupted')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
interruptedHandler(
new CustomEvent('execution_interrupted', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
executed: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
})
describe('unbindExecutionEvents cancels pending RAFs', () => {
it('cancels pending progress RAF on unbind', () => {
const handler = getRegisteredHandler('progress')
handler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('cancels pending progress_state RAF on unbind', () => {
const handler = getRegisteredHandler('progress_state')
handler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 0,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
})

View File

@@ -33,6 +33,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import { createRafCoalescer } from '@/utils/rafBatch'
interface QueuedJob {
/**
@@ -242,6 +243,8 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
cancelPendingProgressUpdates()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -290,6 +293,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecuting(e: CustomEvent<NodeId | null>): void {
// Cancel any pending progress RAF before clearing state to prevent
// stale data from being written back on the next frame.
progressCoalescer.cancel()
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -332,8 +339,15 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStatesByJob.value = pruned
}
const progressStateCoalescer =
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes, prompt_id: jobId } = e.detail
progressStateCoalescer.push(e.detail)
}
function _applyProgressState(detail: ProgressStateWsMessage) {
const { nodes, prompt_id: jobId } = detail
// Revoke previews for nodes that are starting to execute
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
@@ -369,8 +383,17 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
_executingNodeProgress.value = detail
})
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
_executingNodeProgress.value = e.detail
progressCoalescer.push(e.detail)
}
function cancelPendingProgressUpdates() {
progressCoalescer.cancel()
progressStateCoalescer.cancel()
}
function handleStatus() {
@@ -492,6 +515,8 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
cancelPendingProgressUpdates()
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null

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 { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { createTestingPinia } from '@pinia/testing'
// Hoisted mocks for dynamic imports
@@ -122,8 +122,8 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
})
}))
describe('useAuthStore', () => {
let store: ReturnType<typeof useAuthStore>
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
@@ -182,7 +182,7 @@ describe('useAuthStore', () => {
// Initialize Pinia
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAuthStore()
store = useFirebaseAuthStore()
// Reset and set up getIdToken mock
mockUser.getIdToken.mockReset()
@@ -210,8 +210,8 @@ describe('useAuthStore', () => {
)
setActivePinia(createTestingPinia({ stubActions: false }))
const storeModule = await import('@/stores/authStore')
store = storeModule.useAuthStore()
const storeModule = await import('@/stores/firebaseAuthStore')
store = storeModule.useFirebaseAuthStore()
})
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 AuthStoreError extends Error {
export class FirebaseAuthStoreError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthStoreError'
this.name = 'FirebaseAuthStoreError'
}
}
export const useAuthStore = defineStore('auth', () => {
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const { flags } = useFeatureFlags()
// State
@@ -241,7 +241,9 @@ export const useAuthStore = defineStore('auth', () => {
try {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const response = await fetch(buildApiUrl('/customers/balance'), {
@@ -257,7 +259,7 @@ export const useAuthStore = defineStore('auth', () => {
return null
}
const errorData = await response.json()
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
@@ -277,7 +279,7 @@ export const useAuthStore = defineStore('auth', () => {
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(buildApiUrl('/customers'), {
@@ -288,7 +290,7 @@ export const useAuthStore = defineStore('auth', () => {
}
})
if (!createCustomerRes.ok) {
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: createCustomerRes.statusText
})
@@ -298,7 +300,7 @@ export const useAuthStore = defineStore('auth', () => {
const createCustomerResJson: CreateCustomerResponse =
await createCustomerRes.json()
if (!createCustomerResJson?.id) {
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: 'No customer ID returned'
})
@@ -429,7 +431,7 @@ export const useAuthStore = defineStore('auth', () => {
/** Update password for current user */
const _updatePassword = async (newPassword: string): Promise<void> => {
if (!currentUser.value) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await updatePassword(currentUser.value, newPassword)
}
@@ -439,7 +441,7 @@ export const useAuthStore = defineStore('auth', () => {
): Promise<CreditPurchaseResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
@@ -459,7 +461,7 @@ export const useAuthStore = defineStore('auth', () => {
if (!response.ok) {
const errorData = await response.json()
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
@@ -479,7 +481,7 @@ export const useAuthStore = defineStore('auth', () => {
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/billing'), {
@@ -495,7 +497,7 @@ export const useAuthStore = defineStore('auth', () => {
if (!response.ok) {
const errorData = await response.json()
throw new AuthStoreError(
throw new FirebaseAuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})

View File

@@ -13,7 +13,7 @@ import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
import { useApiKeyAuthStore } from './apiKeyAuthStore'
import { useCommandStore } from './commandStore'
import { useExecutionErrorStore } from './executionErrorStore'
import { useAuthStore } from './authStore'
import { useFirebaseAuthStore } from './firebaseAuthStore'
import { useQueueSettingsStore } from './queueStore'
import { useBottomPanelStore } from './workspace/bottomPanelStore'
import { useSidebarTabStore } from './workspace/sidebarTabStore'
@@ -48,7 +48,7 @@ function workspaceStoreSetup() {
const dialog = useDialogService()
const bottomPanel = useBottomPanelStore()
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const firebaseUser = computed(() => authStore.currentUser)

View File

@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafCoalescer } from '@/utils/rafBatch'
describe('createRafCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('applies the latest pushed value on the next frame', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.push(2)
coalescer.push(3)
expect(apply).not.toHaveBeenCalled()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(3)
})
it('does not apply after cancel', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(42)
coalescer.cancel()
vi.advanceTimersByTime(16)
expect(apply).not.toHaveBeenCalled()
})
it('applies immediately on flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(99)
coalescer.flush()
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(99)
})
it('does nothing on flush when no value is pending', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.flush()
expect(apply).not.toHaveBeenCalled()
})
it('does not double-apply after flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.flush()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
})
it('reports scheduled state correctly', () => {
const coalescer = createRafCoalescer<number>(vi.fn())
expect(coalescer.isScheduled()).toBe(false)
coalescer.push(1)
expect(coalescer.isScheduled()).toBe(true)
vi.advanceTimersByTime(16)
expect(coalescer.isScheduled()).toBe(false)
})
})

View File

@@ -27,3 +27,40 @@ export function createRafBatch(run: () => void) {
return { schedule, cancel, flush, isScheduled }
}
/**
* Last-write-wins RAF coalescer. Buffers the latest value and applies it
* on the next animation frame, coalescing multiple pushes into a single
* reactive update.
*/
export function createRafCoalescer<T>(apply: (value: T) => void) {
let hasPending = false
let pendingValue: T | undefined
const batch = createRafBatch(() => {
if (!hasPending) return
const value = pendingValue as T
hasPending = false
pendingValue = undefined
apply(value)
})
const push = (value: T) => {
pendingValue = value
hasPending = true
batch.schedule()
}
const cancel = () => {
hasPending = false
pendingValue = undefined
batch.cancel()
}
const flush = () => {
if (!hasPending) return
batch.flush()
}
return { push, cancel, flush, isScheduled: batch.isScheduled }
}

View File

@@ -26,7 +26,6 @@
<ModelImportProgressDialog />
<AssetExportProgressDialog />
<ManagerProgressToast />
<DesktopCloudNotificationController />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<MenuHamburger />
</template>
@@ -64,7 +63,6 @@ import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import DesktopCloudNotificationController from '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -80,7 +78,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useModelStore } from '@/stores/modelStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -122,7 +120,7 @@ watch(linearMode, (isLinear) => {
})
const telemetry = useTelemetry()
const authStore = useAuthStore()
const firebaseAuthStore = useFirebaseAuthStore()
let hasTrackedLogin = false
watch(
@@ -310,7 +308,7 @@ void nextTick(() => {
const onGraphReady = () => {
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {
if (isCloud && firebaseAuthStore.isAuthenticated && !hasTrackedLogin) {
telemetry?.trackUserLoggedIn()
hasTrackedLogin = true
}