Compare commits

..

15 Commits

Author SHA1 Message Date
GitHub Action
24a3b28fb9 [automated] Apply ESLint and Oxfmt fixes 2026-02-06 02:36:54 +00:00
bymyself
6ac72fafcc fix: use current graph state for node replacement to preserve prior replacements
Amp-Thread-ID: https://ampcode.com/threads/T-019c3047-baa8-757f-b271-4be924f4cde3
2026-02-05 18:34:25 -08:00
bymyself
f50afee6c4 test: add browser test for node replacement UI
Amp-Thread-ID: https://ampcode.com/threads/T-019c3047-baa8-757f-b271-4be924f4cde3
2026-02-05 18:12:46 -08:00
Jin Yi
3b3430e2d8 Merge branch 'feature/node-replacement-classify' into feature/node-replacement-ui 2026-02-05 15:16:30 +09:00
Jin Yi
52a46e72c9 Merge branch 'main' into feature/node-replacement-classify 2026-02-05 15:15:47 +09:00
Jin Yi
513dd0e426 feature: replace nodes 2026-02-04 14:30:26 +09:00
Jin Yi
1fc34dfd6a feature: isEnabled flag added & hardcoding deleted 2026-02-04 13:13:18 +09:00
Jin Yi
24612b2082 Merge branch 'main' into feature/node-replacement-classify 2026-02-04 12:07:35 +09:00
Jin Yi
7a7a1a5e70 feat: classify missing nodes by replacement availability 2026-01-30 15:59:23 +09:00
Jin Yi
4c2f2a910f chore: addressed code review 2026-01-30 14:33:40 +09:00
Jin Yi
31177bc036 Merge branch 'main' into feature/node-deprecated-store 2026-01-30 14:20:59 +09:00
Jin Yi
8390838ed2 chore: service folder deleted 2026-01-29 17:10:23 +09:00
Jin Yi
ee0c0e9996 Merge branch 'main' into feature/node-deprecated-store 2026-01-29 17:08:02 +09:00
Jin Yi
4b57a12ca5 fix: applied based on code review 2026-01-29 17:00:17 +09:00
Jin Yi
07c8b822bc feature: node deprecated store + service 2026-01-28 15:46:30 +09:00
242 changed files with 1753 additions and 8995 deletions

View File

@@ -1,200 +0,0 @@
---
name: writing-playwright-tests
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
---
# Writing Playwright Tests for ComfyUI_frontend
## Golden Rules
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
- Assets live in `browser_tests/assets/`
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
## Vue Nodes vs LiteGraph: Decision Guide
Choose based on **what you're testing**, not personal preference:
| Testing... | Use | Why |
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
| Both in same test | Pick primary, minimize switching | Avoid confusion |
**Vue Nodes requires explicit opt-in:**
```typescript
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
```
**Vue Node state uses CSS classes:**
```typescript
const BYPASS_CLASS = /before:bg-bypass\/60/
await expect(node).toHaveClass(BYPASS_CLASS)
```
## Common Issues
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
| Symptom | Common Cause | Typical Fix |
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
## Test Tags
Add appropriate tags to every test:
| Tag | When to Use |
| ------------- | ----------------------------------------- |
| `@smoke` | Quick essential tests |
| `@slow` | Tests > 10 seconds |
| `@screenshot` | Visual regression tests |
| `@canvas` | Canvas interactions |
| `@node` | Node-related |
| `@widget` | Widget-related |
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
| `@2x` | HiDPI tests (runs on 2x scale project) |
```typescript
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
```
## Retry Patterns
**Never use `waitForTimeout`** - it's always wrong.
| Pattern | Use Case |
| ------------------------ | ---------------------------------------------------- |
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
| `expect.poll()` | Single value polling |
| `expect().toPass()` | Multiple assertions that must all pass |
```typescript
// Prefer auto-retrying assertions when possible
await expect(node).toBeVisible()
// Single value polling
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
// Multiple conditions
await expect(async () => {
expect(await node1.getValue()).toBe('foo')
expect(await node2.getValue()).toBe('bar')
}).toPass({ timeout: 2000 })
```
## Screenshot Baselines
- **Screenshots are Linux-only.** Don't commit local screenshots.
- **To update baselines:** Add PR label `New Browser Test Expectations`
- **Mask dynamic content:**
```typescript
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
mask: [page.locator('.timestamp')]
})
```
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `npx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`
## Anti-Patterns
Avoid these common mistakes:
1. **Arbitrary waits** - Use retrying assertions instead
```typescript
// ❌ await page.waitForTimeout(500)
// ✅ await expect(element).toBeVisible()
```
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
```typescript
// ❌ page.locator('div.container > button.btn-primary')
// ✅ page.getByTestId('submit-button')
```
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
```typescript
await node.drag({ x: 50, y: 50 })
await comfyPage.nextFrame() // Required
```
4. **Shared state between tests** - Tests must be independent
```typescript
// ❌ let sharedData // Outside test
// ✅ Define state inside each test
```
## Quick Start Template
```typescript
// Path depends on test file location - adjust '../' segments accordingly
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('should do something', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('myWorkflow')
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
// ... test logic
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
})
})
```
## Finding Patterns
```bash
# Find similar tests
grep -r "KSampler" browser_tests/tests/
# Find usage of a fixture method
grep -r "loadWorkflow" browser_tests/tests/
# Find tests with specific tag
grep -r '@screenshot' browser_tests/tests/
```
## Key Files to Read
| Purpose | Path |
| ----------------- | ------------------------------------------ |
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
| Helper classes | `browser_tests/fixtures/helpers/` |
| Component objects | `browser_tests/fixtures/components/` |
| Test selectors | `browser_tests/fixtures/selectors.ts` |
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
| Test assets | `browser_tests/assets/` |
| Existing tests | `browser_tests/tests/` |
**Read the fixture code directly** - it's the source of truth for available methods.

View File

@@ -1,52 +0,0 @@
name: 'CI: Dist Telemetry Scan'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
- name: Scan dist for telemetry references
run: |
set -euo pipefail
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'Google Tag Manager' \
-e '(?i)\bgtm\.js\b' \
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
dist; then
echo 'Telemetry references found in dist assets.'
exit 1
fi
echo 'No telemetry references found in dist assets.'

View File

@@ -109,7 +109,7 @@ jobs:
# Run sharded tests with snapshot updates (browsers pre-installed in container)
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright-tests
run: pnpm exec playwright test --update-snapshots --grep @screenshot --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
continue-on-error: true
- name: Stage changed snapshot files

2
.gitignore vendored
View File

@@ -96,5 +96,3 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp

View File

@@ -60,6 +60,11 @@
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": ["st", "t", "te", "d"],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}

View File

@@ -1,8 +1,6 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
import { nextTick, provide } from 'vue'
import type { ElectronWindow } from '@/utils/envUtil'
import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
@@ -44,21 +42,16 @@ const meta: Meta<typeof InstallView> = {
const router = createMockRouter()
// Mock electron API
;(window as ElectronWindow).electronAPI = {
;(window as any).electronAPI = {
getPlatform: () => 'darwin',
Config: {
getDetectedGpu: () => Promise.resolve('mps')
},
Events: {
trackEvent: (
_eventName: string,
_data?: Record<string, unknown>
) => {}
trackEvent: (_eventName: string, _data?: any) => {}
},
installComfyUI: (
_options: Parameters<ElectronAPI['installComfyUI']>[0]
) => {},
changeTheme: (_theme: Parameters<ElectronAPI['changeTheme']>[0]) => {},
installComfyUI: (_options: any) => {},
changeTheme: (_theme: any) => {},
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
@@ -247,8 +240,8 @@ export const DesktopSettings: Story = {
export const WindowsPlatform: Story = {
render: () => {
// Override the platform to Windows
;(window as ElectronWindow).electronAPI.getPlatform = () => 'win32'
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
;(window as any).electronAPI.getPlatform = () => 'win32'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('nvidia')
return {
@@ -266,8 +259,8 @@ export const MacOSPlatform: Story = {
name: 'macOS Platform',
render: () => {
// Override the platform to macOS
;(window as ElectronWindow).electronAPI.getPlatform = () => 'darwin'
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
;(window as any).electronAPI.getPlatform = () => 'darwin'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('mps')
return {
@@ -334,7 +327,7 @@ export const ManualInstall: Story = {
export const ErrorState: Story = {
render: () => {
// Override validation to return an error
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: false,
exists: false,
@@ -382,7 +375,7 @@ export const ErrorState: Story = {
export const WarningState: Story = {
render: () => {
// Override validation to return a warning about non-default drive
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: true,
exists: false,

View File

@@ -4,40 +4,11 @@ See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded fo
## Directory Structure
```text
browser_tests/
├── assets/ - Test data (JSON workflows, images)
├── fixtures/
│ ├── ComfyPage.ts - Main fixture (delegates to helpers)
│ ├── ComfyMouse.ts - Mouse interaction helper
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── components/ - Page object components
│ │ ├── ContextMenu.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ └── Topbar.ts
│ ├── helpers/ - Focused helper classes
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
│ │ ├── KeyboardHelper.ts
│ │ ├── NodeOperationsHelper.ts
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Utility functions
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
- Run `pnpm exec eslint browser_tests/path/to/file.ts` to lint specific files
- Run `pnpm exec oxlint browser_tests/path/to/file.ts` to check with oxlint
## Skill Documentation
A Playwright test-writing skill exists at `.claude/skills/writing-playwright-tests/SKILL.md`.
The skill documents **meta-level guidance only** (gotchas, anti-patterns, decision guides). It does **not** duplicate fixture APIs - agents should read the fixture code directly in `browser_tests/fixtures/`.

View File

@@ -0,0 +1,59 @@
{
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": { "0": 300, "1": 58 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": null
}
],
"properties": { "Node name for S&R": "T2IAdapterLoader" },
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "ImageBatch",
"pos": [100, 200],
"size": { "0": 210, "1": 46 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "image1", "type": "IMAGE", "link": null },
{ "name": "image2", "type": "IMAGE", "link": null }
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [1] }],
"properties": { "Node name for S&R": "ImageBatch" }
},
{
"id": 3,
"type": "UNKNOWN_NO_REPLACEMENT",
"pos": [100, 300],
"size": { "0": 210, "1": 46 },
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "image", "type": "IMAGE", "link": 1 }],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
"properties": { "Node name for S&R": "UNKNOWN_NO_REPLACEMENT" }
}
],
"links": [[1, 2, 0, 3, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] }
},
"version": 0.4
}

View File

@@ -1,9 +1,5 @@
import { test as base } from '@playwright/test'
interface TestWindow extends Window {
__ws__?: Record<string, WebSocket>
}
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
}>({

View File

@@ -34,6 +34,33 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
expect(warningText).toContain('MISSING_NODE_TYPE_IN_SUBGRAPH')
expect(warningText).toContain('in subgraph')
})
test('Should show replacement UI for replaceable missing nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
await comfyPage.workflow.loadWorkflow('missing/replaceable_nodes')
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
await expect(missingNodesWarning).toBeVisible()
// Verify "Replaceable" badges appear for nodes with replacements
const replaceableBadges = missingNodesWarning.getByText('Replaceable')
await expect(replaceableBadges.first()).toBeVisible()
expect(await replaceableBadges.count()).toBeGreaterThanOrEqual(2)
// Verify individual "Replace" buttons appear
const replaceButtons = missingNodesWarning.getByRole('button', {
name: 'Replace'
})
expect(await replaceButtons.count()).toBeGreaterThanOrEqual(2)
// Verify "Replace All" button appears in footer
const replaceAllButton = comfyPage.page.getByRole('button', {
name: 'Replace All'
})
await expect(replaceAllButton).toBeVisible()
})
})
test('Does not report warning on undo/redo', async ({ comfyPage }) => {

View File

@@ -1,162 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
}
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, nodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {
test.describe(`Ghost node placement (${mode} mode)`, () => {
test.beforeEach(async ({ comfyPage }) => {
await setVueMode(comfyPage, mode === 'vue')
})
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const result = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
const canvas = window.app!.canvas
const rect = canvas.canvas.getBoundingClientRect()
const cursorCanvasX =
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
const cursorCanvasY =
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
return {
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
diffY: node.pos[1] - 10 - cursorCanvasY
}
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
})
test('left-click confirms ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY)
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).not.toBeNull()
expect(after!.ghost).toBe(false)
})
test('Escape cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Delete cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('right-click cancels ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -31,12 +31,7 @@ test.describe('Vue Integer Widget', () => {
await expect(seedWidget).toBeVisible()
// Delete the node that is linked to the slot (freeing up the widget)
// Click on the header to select the node (clicking center may land on
// the widget area where pointerdown.stop prevents node selection)
await comfyPage.vueNodes
.getNodeByTitle('Int')
.locator('.lg-node-header')
.click()
await comfyPage.vueNodes.getNodeByTitle('Int').click()
await comfyPage.vueNodes.deleteSelected()
// Test widget works when unlinked

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -279,46 +279,5 @@ export default defineConfig([
'import-x/no-duplicates': 'off',
'import-x/consistent-type-specifier-style': 'off'
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{
files: ['**/*.vue'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@/i18n',
importNames: ['t', 'd', 'te'],
message:
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
}
]
}
]
}
},
// Non-composable .ts files must use the global t/d/te, not useI18n()
{
files: ['**/*.ts'],
ignores: ['**/use[A-Z]*.ts', '**/*.test.ts', 'src/i18n.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'vue-i18n',
importNames: ['useI18n'],
message:
"useI18n() requires Vue setup context. Use `import { t } from '@/i18n'` instead."
}
]
}
]
}
}
])

7
global.d.ts vendored
View File

@@ -7,7 +7,6 @@ declare const __USE_PROD_CONFIG__: boolean
interface Window {
__CONFIG__: {
gtm_container_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -31,12 +30,6 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
}
interface Navigator {

View File

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

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path d="M6.66667 14V5.33333C6.66667 5.15652 6.59643 4.98695 6.4714 4.86193C6.34638 4.7369 6.17681 4.66667 6 4.66667H2.66667C2.48986 4.66667 2.32029 4.7369 2.19526 4.86193C2.07024 4.98695 2 5.15652 2 5.33333V13.3333C2 13.5101 2.07024 13.6797 2.19526 13.8047C2.32029 13.9298 2.48986 14 2.66667 14H10.6667C10.8435 14 11.013 13.9298 11.1381 13.8047C11.2631 13.6797 11.3333 13.5101 11.3333 13.3333V10C11.3333 9.82319 11.2631 9.65362 11.1381 9.5286C11.013 9.40357 10.8435 9.33333 10.6667 9.33333H2M10 2H13.3333C13.7015 2 14 2.29848 14 2.66667V6C14 6.36819 13.7015 6.66667 13.3333 6.66667H10C9.63181 6.66667 9.33333 6.36819 9.33333 6V2.66667C9.33333 2.29848 9.63181 2 10 2Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 837 B

View File

@@ -2,7 +2,10 @@
import { execSync } from 'child_process'
import * as fs from 'fs'
import { globSync } from 'glob'
import type { LocaleData } from './i18n-types'
interface LocaleData {
[key: string]: any
}
// Configuration
const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*']
@@ -42,7 +45,7 @@ function getStagedLocaleFiles(): string[] {
}
// Extract all keys from a nested object
function extractKeys(obj: LocaleData, prefix = ''): string[] {
function extractKeys(obj: any, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
@@ -163,17 +166,17 @@ async function checkNewUnusedKeys() {
// Report results
if (unusedNewKeys.length > 0) {
console.warn('\n⚠ Warning: Found unused NEW i18n keys:\n')
console.log('\n⚠ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort()) {
console.warn(` - ${key}`)
console.log(` - ${key}`)
}
console.warn(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
console.warn(
console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
console.log(
'\nThese keys were added but are not used anywhere in the codebase.'
)
console.warn('Consider using them or removing them in a future update.')
console.log('Consider using them or removing them in a future update.')
// Changed from process.exit(1) to process.exit(0) for warning only
process.exit(0)

View File

@@ -7,7 +7,6 @@ import {
writeFileSync
} from 'fs'
import { dirname, join } from 'path'
import type { LocaleData } from './i18n-types'
// Ensure directories exist
function ensureDir(dir: string) {
@@ -42,8 +41,8 @@ function getAllJsonFiles(dir: string): string[] {
}
// Find additions in new object compared to base
function findAdditions(base: LocaleData, updated: LocaleData): LocaleData {
const additions: LocaleData = {}
function findAdditions(base: any, updated: any): Record<string, any> {
const additions: Record<string, any> = {}
for (const key in updated) {
if (!(key in base)) {
@@ -75,7 +74,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
ensureDir(dirname(targetPath))
writeFileSync(targetPath, readFileSync(file, 'utf8'))
}
console.warn('Captured current locale files to temp/base/')
console.log('Captured current locale files to temp/base/')
}
// Diff command
@@ -95,7 +94,7 @@ function diff(srcLocaleDir: string, tempBaseDir: string, tempDiffDir: string) {
if (Object.keys(additions).length > 0) {
ensureDir(dirname(diffPath))
writeFileSync(diffPath, JSON.stringify(additions, null, 2))
console.warn(`Wrote diff to ${diffPath}`)
console.log(`Wrote diff to ${diffPath}`)
}
}
}
@@ -117,9 +116,9 @@ switch (command) {
// Remove temp directory recursively
if (existsSync('temp')) {
rmSync('temp', { recursive: true, force: true })
console.warn('Removed temp directory')
console.log('Removed temp directory')
}
break
default:
console.error('Please specify either "capture" or "diff" command')
console.log('Please specify either "capture" or "diff" command')
}

View File

@@ -1,5 +0,0 @@
/**
* Shared types for i18n-related scripts
*/
export type LocaleData = { [key: string]: string | LocaleData }

View File

@@ -19,14 +19,12 @@
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
:aria-label="t('menu.manageExtensions')"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[comfy--extensions-blocks] size-4" />
<span class="not-md:hidden">
{{ t('menu.manageExtensions') }}
</span>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
@@ -222,7 +220,7 @@ const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
buildTooltipConfig(t('menu.customNodesManager'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [

View File

@@ -8,10 +8,10 @@
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription } = useSubscription()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton

View File

@@ -38,7 +38,6 @@
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
@@ -56,7 +55,7 @@ const {
alt?: string
containerClass?: ClassValue
imageClass?: ClassValue
imageStyle?: StyleValue
imageStyle?: Record<string, any>
rootMargin?: string
}>()

View File

@@ -25,10 +25,25 @@
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
>
<span class="text-xs">
{{ node.label }}
</span>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
<div class="flex items-center gap-2">
<StatusBadge
v-if="node.isReplaceable"
:label="$t('nodeReplacement.replaceable')"
severity="default"
/>
<span class="text-xs">{{ node.label }}</span>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
<Button
v-if="node.isReplaceable"
variant="secondary"
size="sm"
@click="emit('replace', node.label)"
>
{{ $t('nodeReplacement.replace') }}
</Button>
</div>
</div>
@@ -49,7 +64,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { MissingNodeType } from '@/types/comfy'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -58,6 +75,10 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const emit = defineEmits<{
(e: 'replace', nodeType: string): void
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
@@ -75,10 +96,12 @@ const uniqueNodes = computed(() => {
return {
label: node.type,
hint: node.hint,
action: node.action
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
}
}
return { label: node }
return { label: node, isReplaceable: false }
})
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<!-- Cloud mode: Learn More + Got It buttons -->
<!-- Cloud mode: Learn More + Replace All + Got It buttons -->
<div
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
@@ -15,16 +15,34 @@
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
<div class="flex gap-1">
<Button
v-if="hasReplaceableNodes"
variant="primary"
size="md"
@click="emit('replaceAll')"
>
{{ $t('nodeReplacement.replaceAll') }}
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<!-- OSS mode: Open Manager + Replace All + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<Button
v-if="hasReplaceableNodes"
variant="primary"
size="md"
@click="emit('replaceAll')"
>
{{ $t('nodeReplacement.replaceAll') }}
</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
@@ -51,12 +69,25 @@ import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes = [] } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const emit = defineEmits<{
(e: 'replaceAll'): void
}>()
const hasReplaceableNodes = computed(() =>
missingNodeTypes.some((n) => typeof n === 'object' && n.isReplaceable)
)
const dialogStore = useDialogStore()
const { t } = useI18n()

View File

@@ -158,7 +158,6 @@ import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -177,9 +176,8 @@ const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { flags } = useFeatureFlags()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
@@ -258,15 +256,9 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -1,295 +0,0 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>
<!-- Warnings -->
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
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 { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
if (loading.value || !isValidAmount.value) return
loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
if (response.status === 'completed') {
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
life: 5000
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError'),
life: 5000
})
}
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription } = useSubscription()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)

View File

@@ -1,108 +0,0 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('subscription.cancelDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
:disabled="isLoading"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
{{ $t('subscription.cancelDialog.keepSubscription') }}
</Button>
<Button
variant="destructive"
size="lg"
:loading="isLoading"
@click="onConfirmCancel"
>
{{ $t('subscription.cancelDialog.confirmCancel') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
cancelAt?: string
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const formattedEndDate = computed(() => {
const dateStr = props.cancelAt ?? subscription.value?.endDate
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
})
const description = computed(() =>
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
)
function onClose() {
if (isLoading.value) return
dialogStore.closeDialog({ key: 'cancel-subscription' })
}
async function onConfirmCancel() {
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
<h2 class="px-4">
<i class="pi pi-cog" />
<span>{{ $t('g.settings') }}</span>
<Tag
@@ -16,9 +16,6 @@
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { cn } from '@comfyorg/tailwind-utils'
const { flags } = useFeatureFlags()
</script>
<style scoped>

View File

@@ -76,13 +76,6 @@
/>
</TransformPane>
<LinkOverlayCanvas
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@ready="onLinkOverlayReady"
@dispose="onLinkOverlayDispose"
/>
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
<SelectionRectangle v-if="comfyAppReady" />
@@ -111,7 +104,6 @@ import {
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -119,7 +111,6 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
@@ -138,6 +129,7 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
@@ -175,7 +167,6 @@ import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
const emit = defineEmits<{
ready: []
}>()
@@ -250,18 +241,6 @@ const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return
canvasStore.canvas.overlayCanvas = el
canvasStore.canvas.overlayCtx = el.getContext('2d')
}
function onLinkOverlayDispose() {
if (!canvasStore.canvas) return
canvasStore.canvas.overlayCanvas = null
canvasStore.canvas.overlayCtx = null
}
watchEffect(() => {
LiteGraph.nodeOpacity = settingStore.get('Comfy.Node.Opacity')
})

View File

@@ -1,43 +0,0 @@
<template>
<canvas
ref="canvasRef"
class="pointer-events-none absolute inset-0 size-full"
/>
</template>
<script setup lang="ts">
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useRafFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const { canvas } = defineProps<{
canvas: LGraphCanvas
}>()
const emit = defineEmits<{
ready: [canvas: HTMLCanvasElement]
dispose: []
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useRafFn(() => {
const el = canvasRef.value
const mainCanvas = canvas.canvas
if (!el || !mainCanvas) return
if (el.width !== mainCanvas.width || el.height !== mainCanvas.height) {
el.width = mainCanvas.width
el.height = mainCanvas.height
}
})
onMounted(() => {
if (canvasRef.value) emit('ready', canvasRef.value)
})
onBeforeUnmount(() => {
emit('dispose')
})
</script>

View File

@@ -61,7 +61,7 @@ async function showTooltip(tooltip: string | null | undefined) {
function onIdle() {
const { canvas } = comfyApp
const node = canvas?.node_over
if (!node || node.flags?.ghost) return
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']

View File

@@ -94,7 +94,6 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
@@ -105,11 +104,11 @@ import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneContro
import Button from '@/components/ui/button/Button.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const props = defineProps<{
node?: LGraphNode
modelUrl?: string
@@ -132,7 +131,7 @@ const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
onModelDrop: async (file) => {
await viewer.handleModelDrop(file)
},
disabled: viewer.isPreview.value || !!isStandaloneMode
disabled: viewer.isPreview.value || isStandaloneMode
})
onMounted(async () => {

View File

@@ -94,16 +94,15 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const {
hideMaterialMode = false,
isPlyModel = false,

View File

@@ -19,15 +19,13 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const { node } = defineProps<{
node: LGraphNode
}>()

View File

@@ -30,11 +30,10 @@
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const { t } = useI18n()
const cameras = [
{ title: t('load3d.cameraType.perspective'), value: 'perspective' },
{ title: t('load3d.cameraType.orthographic'), value: 'orthographic' }

View File

@@ -25,14 +25,13 @@
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const { t } = useI18n()
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean

View File

@@ -160,15 +160,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { cn } from '@/utils/tailwindUtil'
import SliderControl from './controls/SliderControl.vue'
const { t } = useI18n()
const store = useMaskEditorStore()
const colorInputRef = ref<HTMLInputElement>()

View File

@@ -61,16 +61,14 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import DropdownControl from './controls/DropdownControl.vue'
import SliderControl from './controls/SliderControl.vue'
import ToggleControl from './controls/ToggleControl.vue'
const { t } = useI18n()
const store = useMaskEditorStore()
const methodOptions = Object.values(ColorComparisonMethod)

View File

@@ -131,12 +131,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
@@ -145,7 +145,6 @@ const { toolManager } = defineProps<{
toolManager?: ReturnType<typeof useToolManager>
}>()
const { t } = useI18n()
const store = useMaskEditorStore()
const canvasManager = useCanvasManager()

View File

@@ -27,13 +27,11 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const { t } = useI18n()
const store = useMaskEditorStore()
const onToleranceChange = (value: number) => {

View File

@@ -31,19 +31,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
import type { Tools } from '@/extensions/core/maskeditor/types'
import { allTools } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { toolManager } = defineProps<{
toolManager: ReturnType<typeof useToolManager>
}>()
const { t } = useI18n()
const store = useMaskEditorStore()
const onToolSelect = (tool: Tools) => {

View File

@@ -128,16 +128,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { t } = useI18n()
const store = useMaskEditorStore()
const dialogStore = useDialogStore()
const canvasTools = useCanvasTools()

View File

@@ -44,7 +44,6 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,

View File

@@ -158,7 +158,7 @@ export const Queued: Story = {
prompt_id: 'p1'
}
}
}
} as any
return { args: { ...args, jobId } }
},
@@ -217,7 +217,7 @@ export const QueuedParallel: Story = {
prompt_id: 'p2'
}
}
}
} as any
return { args: { ...args, jobId } }
},
@@ -258,7 +258,7 @@ export const Running: Story = {
prompt_id: 'p1'
}
}
}
} as any
return { args: { ...args, jobId } }
},
@@ -303,7 +303,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
prompt_id: 'p1'
}
}
}
} as any
return { args: { ...args, jobId } }
},
@@ -360,7 +360,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
prompt_id: 'p2'
}
}
}
} as any
return { args: { ...args, jobId } }
},

View File

@@ -36,7 +36,6 @@ const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)

View File

@@ -46,7 +46,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)

View File

@@ -138,6 +138,7 @@ describe('flatAndCategorizeSelectedItems', () => {
expect(result.nodes).toEqual([testNode1])
expect(result.groups).toEqual([testGroup1, testGroup2])
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup2)
expect(result.nodeToParentGroup.has(testGroup2 as any)).toBe(false)
})
it('should handle mixed selection of nodes and groups', () => {

View File

@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'icon-[comfy--extensions-blocks]',
icon: 'icon-[lucide--puzzle]',
command: showManageExtensions
}
])

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { t } from '@/i18n'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()

View File

@@ -58,7 +58,6 @@
import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
@@ -67,6 +66,7 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -84,7 +84,6 @@ import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const userStore = useUserStore()

View File

@@ -1,314 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const mocks = vi.hoisted(() => ({
shiftKey: { value: true },
ctrlKey: { value: false },
metaKey: { value: false },
outputMedia: [] as AssetItem[]
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useDebounceFn: <T extends (...args: never[]) => unknown>(fn: T) => fn,
useElementHover: () => ref(false),
useResizeObserver: () => undefined,
useStorage: <T>(_key: string, initialValue: T) => ref(initialValue),
useKeyModifier: (key: string) => {
if (key === 'Shift') return mocks.shiftKey
if (key === 'Control') return mocks.ctrlKey
if (key === 'Meta') return mocks.metaKey
return ref(false)
}
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
n: (value: number) => String(value)
}),
createI18n: () => ({
global: {
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: vi.fn()
})
}))
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: (type: 'input' | 'output') => ({
loading: ref(false),
error: ref(null),
media: ref(type === 'output' ? mocks.outputMedia : []),
fetchMediaList: vi.fn(async () => {}),
hasMore: ref(false),
isLoadingMore: ref(false),
loadMore: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetFiltering', () => ({
useMediaAssetFiltering: (baseAssets: { value: AssetItem[] }) => ({
searchQuery: ref(''),
sortBy: ref('newest'),
mediaTypeFilters: ref([]),
filteredAssets: computed(() => baseAssets.value)
})
}))
vi.mock('@/platform/assets/composables/useOutputStacks', () => ({
useOutputStacks: ({ assets }: { assets: { value: AssetItem[] } }) => ({
assetItems: computed(() =>
assets.value.map((asset) => ({ key: asset.id, asset }))
),
selectableAssets: computed(() => assets.value),
isStackExpanded: () => false,
toggleStack: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => ({
downloadMultipleAssets: vi.fn(),
deleteAssets: vi.fn(async () => true),
addMultipleToWorkflow: vi.fn(async () => {}),
openMultipleWorkflows: vi.fn(async () => {}),
exportMultipleWorkflows: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
getAssetType: (tags: unknown) =>
Array.isArray(tags) && tags.includes('output') ? 'output' : 'input'
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => false
})
}))
vi.mock('@/stores/queueStore', () => {
class ResultItemImpl {
filename: string
subfolder: string
type: string
nodeId: string
mediaType: string
constructor({
filename,
subfolder,
type,
nodeId,
mediaType
}: {
filename: string
subfolder: string
type: string
nodeId: string
mediaType: string
}) {
this.filename = filename
this.subfolder = subfolder
this.type = type
this.nodeId = nodeId
this.mediaType = mediaType
}
}
return {
useQueueStore: () => ({
activeJobsCount: ref(0),
pendingTasks: []
}),
ResultItemImpl
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn(async () => {})
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByPromptIds: vi.fn()
})
}))
vi.mock('@/utils/formatUtil', () => ({
formatDuration: (duration: number) => `${duration}ms`,
getMediaTypeFromFilename: () => 'image'
}))
vi.mock('@/utils/tailwindUtil', () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
resolveOutputAssetItems: vi.fn(async () => [])
}
})
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
const sidebarTabTemplateStub = {
template: `
<div>
<slot name="alt-title" />
<slot name="tool-buttons" />
<slot name="header" />
<slot name="body" />
<slot name="footer" />
</div>
`
}
const buttonStub = {
template: '<button @click="$emit(\'click\', $event)"><slot /></button>'
}
const assetsSidebarGridViewStub = {
props: {
assets: {
type: Array,
required: true
}
},
template: `
<div>
<button
v-for="asset in assets"
:key="asset.id"
:data-testid="'asset-' + asset.id"
@click.stop="$emit('select-asset', asset, assets)"
>
{{ asset.name }}
</button>
</div>
`
}
function createAsset(
id: string,
name: string,
userMetadata?: Record<string, unknown>
): AssetItem {
return {
id,
name,
tags: ['output'],
user_metadata: userMetadata
}
}
describe('AssetsSidebarTab', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.shiftKey.value = true
mocks.ctrlKey.value = false
mocks.metaKey.value = false
mocks.outputMedia = []
})
it('shows deduplicated selected count for parent stack and selected children', async () => {
const outputs = [
{
filename: 'parent.png',
nodeId: '1',
subfolder: 'outputs',
url: 'https://example.com/parent.png'
}
]
const parent = createAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: outputs
})
const child1 = createAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
const child2 = createAsset('child-2', 'child-2.png', {
promptId: 'prompt-1',
nodeId: '3',
subfolder: 'outputs'
})
const child3 = createAsset('child-3', 'child-3.png', {
promptId: 'prompt-1',
nodeId: '4',
subfolder: 'outputs'
})
mocks.outputMedia = [parent, child1, child2, child3]
const wrapper = mount(AssetsSidebarTab, {
global: {
stubs: {
SidebarTabTemplate: sidebarTabTemplateStub,
Button: buttonStub,
TabList: true,
Tab: true,
Divider: true,
ProgressSpinner: true,
NoResultsPlaceholder: true,
MediaAssetFilterBar: true,
AssetsSidebarListView: true,
AssetsSidebarGridView: assetsSidebarGridViewStub,
ResultGallery: true,
MediaAssetContextMenu: true
},
mocks: {
$t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}
}
})
await wrapper.find('[data-testid="asset-parent"]').trigger('click')
await wrapper.find('[data-testid="asset-child-3"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain(
'mediaAsset.selection.selectedCount:{"count":4}'
)
expect(wrapper.text()).not.toContain(
'mediaAsset.selection.selectedCount:{"count":7}'
)
})
})

View File

@@ -1,13 +1,5 @@
<template>
<Toast />
<Toast group="billing-operation" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2">
<i class="pi pi-spin pi-spinner text-primary" />
<span>{{ slotProps.message.summary }}</span>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">

View File

@@ -10,8 +10,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { t } from '@/i18n'
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
import TopbarBadge from './TopbarBadge.vue'
@@ -31,8 +31,6 @@ withDefaults(
}
)
const { t } = useI18n()
const cloudBadge = computed<TopbarBadgeType>(() => ({
label: t('g.beta'),
text: 'Comfy Cloud'

View File

@@ -64,10 +64,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
// Mock the CurrentUserPopover component
vi.mock('./CurrentUserPopover.vue', () => ({
default: {
name: 'CurrentUserPopoverLegacyMock',
name: 'CurrentUserPopoverMock',
render() {
return h('div', 'Popover Content')
},

View File

@@ -45,16 +45,14 @@
class: 'rounded-lg w-80'
}
}"
@show="onPopoverShow"
>
<!-- Workspace mode: workspace-aware popover (only when ready) -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled && initState === 'ready'"
ref="workspacePopoverContent"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopoverLegacy
<CurrentUserPopover
v-else-if="!teamWorkspacesEnabled"
@close="closePopover"
/>
@@ -77,7 +75,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
import CurrentUserPopover from './CurrentUserPopover.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
@@ -114,15 +112,8 @@ const workspaceName = computed(() => {
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const workspacePopoverContent = ref<{
refreshBalance: () => void
} | null>(null)
const closePopover = () => {
popover.value?.hide()
}
const onPopoverShow = () => {
workspacePopoverContent.value?.refreshBalance()
}
</script>

View File

@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
import CurrentUserPopover from './CurrentUserPopover.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
@@ -172,7 +172,7 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
}
}))
describe('CurrentUserPopoverLegacy', () => {
describe('CurrentUserPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthStoreState.balance = {
@@ -190,7 +190,7 @@ describe('CurrentUserPopoverLegacy', () => {
messages: { en: enMessages }
})
return mount(CurrentUserPopoverLegacy, {
return mount(CurrentUserPopover, {
global: {
plugins: [i18n],
stubs: {

View File

@@ -87,26 +87,18 @@
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Show pricing table -->
<!-- Non-personal workspace: Navigate to workspace settings -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
@click="handleOpenPlanAndCreditsSettings"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
{{ $t('workspaceSwitcher.subscribe') }}
</Button>
</div>
@@ -204,19 +196,18 @@ import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref } from 'vue'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -242,33 +233,22 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const { isActiveSubscription, subscriptionStatus } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const isLoadingBalance = isLoading
const displayedCredits = computed(() => {
if (initState.value !== 'ready') return ''
// Wait for subscription to load
if (subscription.value === null) return ''
if (!isActiveSubscription.value) return '0'
// API field is named _micros but contains cents (naming inconsistency)
const cents =
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
// Only personal workspaces have subscription status from useSubscription()
// Team workspaces don't have backend subscription data yet
if (isPersonalWorkspace.value) {
// Wait for subscription status to load
if (subscriptionStatus.value === null) return ''
return isActiveSubscription.value ? totalCredits.value : '0'
}
return '0'
})
const canUpgrade = computed(() => {
@@ -342,11 +322,7 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
const refreshBalance = () => {
if (isActiveSubscription.value) {
void fetchBalance()
}
}
defineExpose({ refreshBalance })
onMounted(() => {
void authActions.fetchBalance()
})
</script>

View File

@@ -37,18 +37,17 @@
import Popover from 'primevue/popover'
import type { HTMLAttributes } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useExternalLink } from '@/composables/useExternalLink'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { t } = useI18n()
const { isLoggedIn, handleSignIn } = useCurrentUser()
const { buildDocsUrl } = useExternalLink()
const apiNodesOverviewUrl = buildDocsUrl(

View File

@@ -113,24 +113,24 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
SubscriptionTier,
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: string | null
subscriptionTier: SubscriptionTier | null
subscriptionPlan: SubscriptionPlan
}
const emit = defineEmits<{
@@ -140,34 +140,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscription } = useBillingContext()
const tierKeyMap: Record<string, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDER: 'founder',
FOUNDERS_EDITION: 'founder'
}
function formatTierName(
tier: string | null | undefined,
isYearly: boolean
): string {
if (!tier) return ''
const key = tierKeyMap[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
return isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
}
const currentSubscriptionTierName = computed(() => {
const tier = subscription.value?.tier
if (!tier) return ''
const isYearly = subscription.value?.duration === 'ANNUAL'
return formatTierName(tier, isYearly)
})
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
@@ -180,8 +153,7 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
type: w.type,
role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan,
subscriptionTier: w.subscriptionTier
subscriptionPlan: w.subscriptionPlan
}))
)
@@ -196,32 +168,19 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
}
function getTierLabel(workspace: AvailableWorkspace): string | null {
// For the current/active workspace, use billing context directly
// This ensures we always have the most up-to-date subscription info
if (isCurrentWorkspace(workspace)) {
return currentSubscriptionTierName.value || null
// Personal workspace: use user's subscription tier
if (workspace.type === 'personal') {
return userSubscriptionTierName.value || null
}
// For non-active workspaces, use cached store data
if (!workspace.isSubscribed) return null
if (workspace.subscriptionTier) {
return formatTierName(workspace.subscriptionTier, false)
}
if (!workspace.subscriptionPlan) return null
// Parse plan slug (format: TIER_DURATION, e.g. "CREATOR_MONTHLY", "PRO_YEARLY")
const planSlug = workspace.subscriptionPlan
// Extract tier from plan slug (e.g., "CREATOR_MONTHLY" -> "CREATOR")
const tierMatch = Object.keys(tierKeyMap).find((tier) =>
planSlug.startsWith(tier)
)
if (!tierMatch) return null
const isYearly = planSlug.includes('YEARLY') || planSlug.includes('ANNUAL')
return formatTierName(tierMatch, isYearly)
// Team workspace: use workspace subscription plan
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (workspace.subscriptionPlan === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {

View File

@@ -2,11 +2,11 @@ import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
@@ -83,7 +83,7 @@ export const useFirebaseAuthActions = () => {
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription } = useSubscription()
if (!isActiveSubscription.value) return
const response = await authStore.initiateCreditPurchase({

View File

@@ -1,76 +0,0 @@
import type { ComputedRef, Ref } from 'vue'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
export interface SubscriptionInfo {
isActive: boolean
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
renewalDate: string | null
endDate: string | null
isCancelled: boolean
hasFunds: boolean
}
export interface BalanceInfo {
amountMicros: number
currency: string
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
}
export interface BillingActions {
initialize: () => Promise<void>
fetchStatus: () => Promise<void>
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
* Shows subscription dialog if not subscribed.
* Use this in extensions/entry points that require active subscription.
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
*/
showSubscriptionDialog: () => void
}
export interface BillingState {
isInitialized: Ref<boolean>
subscription: ComputedRef<SubscriptionInfo | null>
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
}
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
}

View File

@@ -1,164 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
showSubscriptionDialog: vi.fn()
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn(),
hide: vi.fn()
})
})
)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
balance: { amount_micros: 5000000 },
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
}),
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
}
}))
describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('returns legacy type for personal workspace', () => {
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
expect(subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
endDate: null,
isCancelled: false,
hasFunds: true
})
})
it('provides balance info from legacy billing', () => {
const { balance } = useBillingContext()
expect(balance.value).toEqual({
amountMicros: 5000000,
currency: 'usd',
effectiveBalanceMicros: 5000000,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
})
it('exposes initialize action', async () => {
const { initialize } = useBillingContext()
await expect(initialize()).resolves.toBeUndefined()
})
it('exposes fetchStatus action', async () => {
const { fetchStatus } = useBillingContext()
await expect(fetchStatus()).resolves.toBeUndefined()
})
it('exposes fetchBalance action', async () => {
const { fetchBalance } = useBillingContext()
await expect(fetchBalance()).resolves.toBeUndefined()
})
it('exposes subscribe action', async () => {
const { subscribe } = useBillingContext()
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
})
it('exposes manageSubscription action', async () => {
const { manageSubscription } = useBillingContext()
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
})
it('exposes requireActiveSubscription action', async () => {
const { requireActiveSubscription } = useBillingContext()
await expect(requireActiveSubscription()).resolves.toBeUndefined()
})
it('exposes showSubscriptionDialog action', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
})

View File

@@ -1,242 +0,0 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from './useWorkspaceBilling'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
*
* @example
* ```typescript
* const {
* type,
* subscription,
* balance,
* isInitialized,
* initialize,
* subscribe
* } = useBillingContext()
*
* // Wait for initialization
* await initialize()
*
* // Check subscription status
* if (subscription.value?.isActive) {
* console.log(`Tier: ${subscription.value.tier}`)
* }
*
* // Check balance
* if (balance.value) {
* const dollars = balance.value.amountMicros / 1_000_000
* console.log(`Balance: $${dollars.toFixed(2)}`)
* }
* ```
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
)
const workspaceBillingRef = shallowRef<
(BillingState & BillingActions) | null
>(null)
const getLegacyBilling = () => {
if (!legacyBillingRef.value) {
legacyBillingRef.value = useLegacyBilling()
}
return legacyBillingRef.value
}
const getWorkspaceBilling = () => {
if (!workspaceBillingRef.value) {
workspaceBillingRef.value = useWorkspaceBilling()
}
return workspaceBillingRef.value
}
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
// Proxy state from active context
const subscription = computed<SubscriptionInfo | null>(() =>
toValue(activeContext.value.subscription)
)
const balance = computed<BalanceInfo | null>(() =>
toValue(activeContext.value.balance)
)
const plans = computed(() => toValue(activeContext.value.plans))
const currentPlanSlug = computed(() =>
toValue(activeContext.value.currentPlanSlug)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
subscriptionPlan: sub.planSlug
})
},
{ immediate: true }
)
// Initialize billing when workspace changes
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
}
},
{ immediate: true }
)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
return activeContext.value.fetchStatus()
}
async function fetchBalance(): Promise<void> {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
}
async function manageSubscription() {
return activeContext.value.manageSubscription()
}
async function cancelSubscription() {
return activeContext.value.cancelSubscription()
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
async function requireActiveSubscription() {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {
type,
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}
export const useBillingContext = createSharedComposable(
useBillingContextInternal
)

View File

@@ -1,189 +0,0 @@
import { computed, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for legacy user-scoped billing via /customers/* endpoints.
* Used for personal workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useLegacyBilling(): BillingState & BillingActions {
const {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
subscribe: legacySubscribe,
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const firebaseAuthStore = useFirebaseAuthStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
return null
}
return {
isActive: legacyIsActiveSubscription.value,
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = firebaseAuthStore.balance
if (!legacyBalance) return null
return {
amountMicros: legacyBalance.amount_micros ?? 0,
currency: legacyBalance.currency ?? 'usd',
effectiveBalanceMicros:
legacyBalance.effective_balance_micros ??
legacyBalance.amount_micros ??
0,
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
}
})
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
await legacyFetchStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
await firebaseAuthStore.fetchBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null
}
async function manageSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function cancelSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
}
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -1,311 +0,0 @@
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.
* Used for team workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useWorkspaceBilling(): BillingState & BillingActions {
const billingPlans = useBillingPlans()
const workspaceStore = useTeamWorkspaceStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const statusData = shallowRef<BillingStatusResponse | null>(null)
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
const isActiveSubscription = computed(
() => statusData.value?.is_active ?? false
)
const subscription = computed<SubscriptionInfo | null>(() => {
const status = statusData.value
if (!status) return null
return {
isActive: status.is_active,
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: null, // Workspace billing uses cancel_at for end date
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds
}
})
const balance = computed<BalanceInfo | null>(() => {
const data = balanceData.value
if (!data) return null
return {
amountMicros: data.amount_micros,
currency: data.currency,
effectiveBalanceMicros: data.effective_balance_micros,
prepaidBalanceMicros: data.prepaid_balance_micros,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
}
})
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const pendingCancelOpId = ref<string | null>(null)
let cancelPollTimeout: number | null = null
const stopCancelPolling = () => {
if (cancelPollTimeout !== null) {
window.clearTimeout(cancelPollTimeout)
cancelPollTimeout = null
}
}
async function pollCancelStatus(opId: string): Promise<void> {
stopCancelPolling()
const maxAttempts = 30
let attempt = 0
const poll = async () => {
if (pendingCancelOpId.value !== opId) return
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
workspaceStore.updateActiveWorkspace({
isSubscribed: false
})
return
}
if (response.status === 'failed') {
pendingCancelOpId.value = null
stopCancelPolling()
throw new Error(
response.error_message ?? 'Failed to cancel subscription'
)
}
attempt += 1
if (attempt >= maxAttempts) {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
return
}
} catch (err) {
pendingCancelOpId.value = null
stopCancelPolling()
throw err
}
cancelPollTimeout = window.setTimeout(
() => {
void poll()
},
Math.min(1000 * 2 ** attempt, 5000)
)
}
await poll()
}
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
statusData.value = await workspaceApi.getBillingStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch billing status'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
balanceData.value = await workspaceApi.getBillingBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.subscribe(
planSlug,
returnUrl,
cancelUrl
)
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
throw err
} finally {
isLoading.value = false
}
}
async function previewSubscribe(
planSlug: string
): Promise<PreviewSubscribeResponse | null> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.previewSubscribe(planSlug)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to preview subscription'
throw err
} finally {
isLoading.value = false
}
}
async function manageSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const returnUrl = window.location.href
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
if (response.url) {
window.open(response.url, '_blank')
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to open billing portal'
throw err
} finally {
isLoading.value = false
}
}
async function cancelSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.cancelSubscription()
pendingCancelOpId.value = response.billing_op_id
await pollCancelStatus(response.billing_op_id)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to cancel subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
try {
await billingPlans.fetchPlans()
if (billingPlans.error.value) {
error.value = billingPlans.error.value
}
} finally {
isLoading.value = false
}
}
const subscriptionDialog = useSubscriptionDialog()
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
onBeforeUnmount(() => {
stopCancelPolling()
})
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -1,13 +1,15 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
import type { BottomPanelExtension } from '@/types/extensionTypes'
export function useLogsTerminalTab(): BottomPanelExtension {
const { t } = useI18n()
return {
id: 'logs-terminal',
title: 'Logs',
title: t('g.logs'),
titleKey: 'g.logs',
component: markRaw(LogsTerminal),
type: 'vue'
@@ -15,9 +17,10 @@ export function useLogsTerminalTab(): BottomPanelExtension {
}
export function useCommandTerminalTab(): BottomPanelExtension {
const { t } = useI18n()
return {
id: 'command-terminal',
title: 'Terminal',
title: t('g.terminal'),
titleKey: 'g.terminal',
component: markRaw(CommandTerminal),
type: 'vue'

View File

@@ -52,7 +52,7 @@ export interface SafeWidgetData {
isDOMWidget?: boolean
label?: string
nodeType?: string
options?: IWidgetOptions
options?: IWidgetOptions<unknown>
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
}
@@ -70,7 +70,6 @@ export interface VueNodeData {
color?: string
flags?: {
collapsed?: boolean
ghost?: boolean
pinned?: boolean
}
hasErrors?: boolean
@@ -146,7 +145,7 @@ interface SharedWidgetEnhancements {
/** Widget label */
label?: string
/** Widget options */
options?: IWidgetOptions
options?: Record<string, any>
}
/**
@@ -171,7 +170,7 @@ export function getSharedWidgetEnhancements(
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options as IWidgetOptions
options: widget.options
}
}
@@ -433,7 +432,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
requestAnimationFrame(initializeVueNodeLayout)
}
// Call original callback if provided
@@ -527,15 +526,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
})
break
case 'flags.ghost':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
ghost: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.pinned':
vueNodeData.set(nodeId, {
...currentData,

View File

@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
@@ -32,7 +33,10 @@ function useVueNodeLifecycleIndividual() {
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -47,7 +47,7 @@ export const useComputedWithWidgetWatch = (
const { widgetNames, triggerCanvasRedraw = false } = options
// Create a reactive trigger based on widget values
const widgetValues = ref<Record<string, unknown>>({})
const widgetValues = ref<Record<string, any>>({})
// Initialize widget observers
if (node.widgets) {
@@ -56,7 +56,7 @@ export const useComputedWithWidgetWatch = (
: node.widgets
// Initialize current values
const currentValues: Record<string, unknown> = {}
const currentValues: Record<string, any> = {}
widgetsToObserve.forEach((widget) => {
currentValues[widget.name] = widget.value
})

View File

@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
const { mockGetSetting, mockPendingTasks } = vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockPendingTasks: [] as unknown[]
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))
vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
default: {}
}))
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
pendingTasks: mockPendingTasks
})
}))
describe('useAssetsSidebarTab', () => {
it('hides icon badge when QPO V2 is disabled', () => {
mockGetSetting.mockReturnValue(false)
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {})
const sidebarTab = useAssetsSidebarTab()
expect(typeof sidebarTab.iconBadge).toBe('function')
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('shows pending task count when QPO V2 is enabled', () => {
mockGetSetting.mockReturnValue(true)
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {}, {})
const sidebarTab = useAssetsSidebarTab()
expect(typeof sidebarTab.iconBadge).toBe('function')
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
})
})

View File

@@ -1,7 +1,6 @@
import { markRaw } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
@@ -15,12 +14,6 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
component: markRaw(AssetsSidebarTab),
type: 'vue',
iconBadge: () => {
const settingStore = useSettingStore()
if (!settingStore.get('Comfy.Queue.QPOV2')) {
return null
}
const queueStore = useQueueStore()
return queueStore.pendingTasks.length > 0
? queueStore.pendingTasks.length.toString()

View File

@@ -2,7 +2,7 @@ import type { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -33,7 +33,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
const pos: Point = [...basePos]
const pos = [...basePos]
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -127,13 +127,6 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: true },
showSubscriptionDialog: vi.fn()
}))
}))
describe('useCoreCommands', () => {
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
const baseNode = createMockLGraphNode({ id })

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