Compare commits

..

17 Commits

Author SHA1 Message Date
jaeone94
ebe0538c43 fix: backport shared workflow asset import timing
Backport #12333 to core/1.44.

Resolve the templates.spec conflict and adapt the browser regression test to the 1.44 ComfyPage fixture API.
2026-05-20 13:30:36 +09:00
Dante
08dcc96aa3 [backport core/1.44] fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006) (#12352)
Backport of #12006 to core/1.44.

## Conflict resolution

One conflict in
`src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue`
(imports + dropdown source). On main, the original PR replaced
`useMediaAssets('output')` with `isCloud ? useFlatOutputAssets() :
useAssetsApi('output')`. On `core/1.44`, the local path still goes
through `useMediaAssets`, which itself internally gates `isCloud →
useAssetsApi : useInternalFilesApi`. Resolution preserves the new cloud
branch while keeping `useMediaAssets('output')` for the local path on
this branch:

```ts
const outputMediaAssets = isCloud
  ? useFlatOutputAssets()
  : useMediaAssets('output')
```

All other files auto-merged.

## Verification

- `pnpm typecheck` 
- `pnpm test:unit` — `assetsStore.test.ts` (60) +
`useWidgetSelectItems.test.ts` (40) = 100/100 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12352-backport-core-1-44-fix-stabilize-multi-output-expansion-simplify-cloud-output-fetch-3666d73d3650812986a7c8787a225ee3)
by [Unito](https://www.unito.io)
2026-05-20 12:01:30 +09:00
Comfy Org PR Bot
0a75aca0f3 [backport core/1.44] fix: include share_id when importing published assets (FE-603) (#12255)
Backport of #12055 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12255-backport-core-1-44-fix-include-share_id-when-importing-published-assets-FE-603-3606d73d36508157afc4c5a481ccb290)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 22:22:59 +09:00
Comfy Org PR Bot
47bbb659e6 [backport core/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12290)
Backport of #12052 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12290-backport-core-1-44-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3616d73d365081d697c9e8fb9b0d489d)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 22:22:52 +09:00
Comfy Org PR Bot
b059c22def [backport core/1.44] fix: keep node context menu overflow visible when content fits (#12337)
Backport of #12035 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12337-backport-core-1-44-fix-keep-node-context-menu-overflow-visible-when-content-fits-3656d73d36508101b0d3ee3d2f699804)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-19 22:22:02 +09:00
Comfy Org PR Bot
2806fab735 [backport core/1.44] Fix descriptions on core blueprints (#12258)
Backport of #12220 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12258-backport-core-1-44-Fix-descriptions-on-core-blueprints-3606d73d3650812483eef5010be7580a)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-13 23:51:13 -07:00
Comfy Org PR Bot
1ef579abf4 [backport core/1.44] fix: open node info panel from context menu (#12247)
Backport of #12205 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12247-backport-core-1-44-fix-open-node-info-panel-from-context-menu-3606d73d365081c1af40fe414729f9d2)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 12:25:20 +09:00
Comfy Org PR Bot
9530605c3b [backport core/1.44] fix: clear media upload errors via widget change (#12244)
Backport of #12212 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12244-backport-core-1-44-fix-clear-media-upload-errors-via-widget-change-3606d73d365081f09e28cef419b2a65d)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-14 12:19:47 +09:00
Comfy Org PR Bot
e6ead5631a [backport core/1.44] fix: Load Image preview retains deleted asset (FE-230) (#12129)
Backport of #11493 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12129-backport-core-1-44-fix-Load-Image-preview-retains-deleted-asset-FE-230-35d6d73d36508157ada3db9f8416e113)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-14 11:21:29 +09:00
Comfy Org PR Bot
a9b9de2b10 1.44.19 (#12147)
Patch version increment to 1.44.19

**Base branch:** `core/1.44`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12147-1-44-19-35d6d73d365081489f37e84e53576acc)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-05-14 11:20:34 +09:00
jaeone94
9be62a1845 [backport core/1.44] fix: suppress missing media scan during uploads (#12111) (#12188)
## Summary

Manual backport of #12111 to `core/1.44`.

This suppresses false-positive missing media detection while media
loader nodes are still uploading files from drag/drop, paste, or
file-select flows.

## Conflict Resolution

The cherry-pick conflicted only in
`src/platform/missingMedia/missingMediaScan.test.ts` because the target
branch still has the older annotated-media parameterized test block
around the insertion point. I resolved it by:

- adding the new upload-state tests from #12111 above the existing
annotated-media cases
- keeping the existing release-branch annotated-media `it.each` cases
intact
- using `it.for([false, true])` only for the new upload-state test added
by #12111

## Validation

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
src/platform/missingMedia/missingMediaScan.test.ts
src/composables/node/useNodeImageUpload.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/graph/useErrorClearingHooks.test.ts`

Result: 4 files passed, 87 tests passed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12188-backport-core-1-44-fix-suppress-missing-media-scan-during-uploads-12111-35e6d73d3650812dab83ccf51411cc88)
by [Unito](https://www.unito.io)
2026-05-12 20:37:47 +09:00
Dante
d4d2089663 [backport core/1.44] fix(i18n): clamp unsupported browser locales to a shipped tag (#11712) (#12178)
*PR Created by the Glary-Bot Agent*

---

Backport of #11712 to `core/1.44`.

## Summary

Cherry-picks `ceb993605` ("fix(i18n): clamp unsupported browser locales
to a shipped tag") onto `core/1.44`.

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

## Cherry-pick conflict resolution

One file required manual resolution:

- **`browser_tests/tests/customNodeLocales.spec.ts`** — `modify/delete`
conflict. This file does not exist on `core/1.44` (it was introduced on
`main` after the 1.44 branch cut, in #12132). Dropped the modification
from the backport; the underlying test file is not on the release
branch, so its updates are irrelevant here.

All other files merged cleanly (`src/views/GraphView.vue` had an
auto-merge that resolved without intervention).

## Verification

- `pnpm typecheck` — clean
- `pnpm test:unit src/i18n.test.ts` — **17/17 passing** (covers the new
`resolveSupportedLocale` block and the updated unsupported-locale clamp
behaviour)
- `eslint` on all changed files — clean
- **Manual verification via Playwright on the backport branch**,
simulating a fresh install with `navigator.language='de-DE'`:
- Sidebar `aria-label`s render real strings ("Assets (a)", "Node Library
(n)", "Workflows (w)", "Settings", …) — **no** literal i18n keys like
`sideToolbar.labels.assets`.
- Confirmed `hasLiteralKeys: false` on the rendered DOM, matching the
fixed behaviour from the original PR.
  - Screenshot attached.

## Files

```
 apps/desktop-ui/src/i18n.ts                     |   3 +-
 browser_tests/tests/i18nLocaleFallback.spec.ts  |  47 ++++++++
 browser_tests/tests/templates.spec.ts           |  59 +++++-----
 src/i18n.test.ts                                | 109 +++++++++++++++---
 src/i18n.ts                                     | 145 ++++++++----------------
 src/locales/CONTRIBUTING.md                     |  44 +------
 src/locales/localeConfig.ts                     |  82 ++++++++++++++
 src/platform/settings/constants/coreSettings.ts |  21 +---
 src/views/GraphView.vue                         |  20 ++--
```

Backport-of: #11712
Fixes #10563 on the 1.44 release line


## Screenshots

![Core 1.44 backport — sidebar with navigator.language=de-DE shows real
labels (Assets, Node Library, Workflows...) instead of literal i18n
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/08da2130835cfe011512360b413c21eae085ac145ae94852e9dc841da09d0411/pr-images/1778566788268-adcce911-6973-40c7-8c29-7fb8941587f4.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12178-backport-core-1-44-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-117-35e6d73d3650817dae90d9ae68c7f0c6)
by [Unito](https://www.unito.io)
2026-05-12 18:35:38 +09:00
Comfy Org PR Bot
7aaade0f68 [backport core/1.44] fix: handle annotated output media paths in missing media scan (#12121)
Backport of #12069 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12121-backport-core-1-44-fix-handle-annotated-output-media-paths-in-missing-media-scan-35d6d73d3650818db83ff9fbbe417f8d)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-11 01:35:02 +00:00
Comfy Org PR Bot
e45e249ed9 [backport core/1.44] fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12083)
Backport of #12072 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12083-backport-core-1-44-fix-make-credits-help-icon-a-tooltip-button-in-cloud-user-popover--35a6d73d365081b1a23bd0d78cedcab6)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-08 23:20:10 +09:00
Comfy Org PR Bot
5878840f26 [backport core/1.44] fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12080)
Backport of #12051 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12080-backport-core-1-44-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragged-35a6d73d36508163a660ffec1cb4fa8c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-08 20:55:05 +09:00
Comfy Org PR Bot
128ca823fd [backport core/1.44] fix: remove asset hash verification (#12078)
Backport of #12061 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12078-backport-core-1-44-fix-remove-asset-hash-verification-35a6d73d3650819e91fcf10e6e353080)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-08 09:12:15 +00:00
Comfy Org PR Bot
97a80cad22 [backport core/1.44] refactor: align asset pagination schema (#12064)
Backport of #11899 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12064-backport-core-1-44-refactor-align-asset-pagination-schema-3596d73d365081728e3eee2e852b10ce)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-07 17:36:39 +00:00
57 changed files with 243 additions and 2931 deletions

View File

@@ -282,12 +282,10 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true,
url
mockReleases = true
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -319,7 +317,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto({ url })
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -346,8 +344,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
async goto() {
await this.page.goto(this.url)
}
async nextFrame() {

View File

@@ -1,26 +1,19 @@
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return () => count
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -28,8 +21,7 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -41,39 +33,15 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -147,68 +147,5 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -85,18 +85,21 @@ async function openPanelAndExpectNoMissingMedia(
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.page.goto(
new URL(
`/?share=${sharedWorkflowImportScenario.shareId}`,
comfyPage.url
).toString()
)
await comfyPage.waitForAppReady()
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({

View File

@@ -136,11 +136,16 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
await comfyPage.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
await comfyPage.page.goto(
new URL('/?share=test-share-id', comfyPage.url).toString()
)
await comfyPage.waitForAppReady()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -189,79 +188,4 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -12,22 +12,19 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
node.widgets!.push(node.widgets![0])
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
node.widgets![2] = node.widgets![0]
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
node.widgets!.splice(0, 0, node.widgets![0])
})
await expect(loadCheckpointNode).toHaveCount(4)
})

View File

@@ -1,291 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
const apiMock = vi.hoisted(
() =>
new (class extends EventTarget {
clientId: string | null = 'test-client'
getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] }))
subscribeLogs = vi.fn(async () => {})
})()
)
vi.mock('@/scripts/api', () => ({ api: apiMock }))
const terminalMock = vi.hoisted(() => ({
open: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
reset: vi.fn(),
scrollToBottom: vi.fn(),
onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}))
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: terminalMock,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => {
const { defineComponent, ref } = await import('vue')
const { useTerminal } =
await import('@/composables/bottomPanelTabs/useTerminal')
return {
default: defineComponent({
emits: ['created'],
setup(_, { emit }) {
const root = ref<HTMLElement | undefined>(undefined)
emit('created', useTerminal(root), root)
return () => null
}
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
logsTerminal: {
loadError:
'Unable to load logs, please ensure you have updated your ComfyUI backend.',
resyncError:
'Unable to resync logs after the backend reconnected. Reopen the console to retry.'
}
}
}
})
const renderLogsTerminal = () =>
render(LogsTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: { execution: { clientId: 'test-client' } }
}),
i18n
]
}
})
// Silence the production console.error calls in error-path tests. Vitest
// isolates this file's module graph so the spy does not affect other files.
vi.spyOn(console, 'error').mockImplementation(() => {})
// Resolve a getRawLogs call manually to drive deterministic timing in tests
// that need to observe behavior mid-fetch.
const deferredRawLogs = () => {
let resolve!: (value: { entries: { m: string }[] }) => void
let reject!: (err: unknown) => void
const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('LogsTerminal', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMock.clientId = 'test-client'
})
it('loads logs and subscribes to streaming on mount', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
expect(terminalMock.write).toHaveBeenCalledWith('log line\n')
})
})
it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2)
expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true)
})
// The full sequence must be: reset -> write -> scroll -> subscribe
const resetOrder = terminalMock.reset.mock.invocationCallOrder[0]
const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)!
const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0]
const subscribeOrder =
apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)!
expect(resetOrder).toBeLessThan(writeOrder)
expect(writeOrder).toBeLessThan(scrollOrder)
expect(scrollOrder).toBeLessThan(subscribeOrder)
})
it('aborts an in-flight resync when a second "reconnected" arrives', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
// First resync hangs on getRawLogs
const first = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => first.promise)
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
})
// Second resync resolves immediately
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
})
// Now resolve the first (aborted) resync — none of its side effects must apply
first.resolve({ entries: [{ m: 'stale\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n')
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => {
// Mount's getRawLogs hangs so we can drive the race deterministically.
const mount = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => mount.promise)
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
// Resync wins the race and writes the post-reboot snapshot.
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
// Mount's late response must not stomp on the freshly-reset terminal.
mount.resolve({ entries: [{ m: 'stale-mount\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n')
})
it('surfaces an inline error when the resync fetch fails', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to resync logs')
})
})
it('shows a load error when the initial fetch fails', async () => {
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
})
it('recovers from an initial load failure when a reconnect arrives', async () => {
apiMock.getRawLogs
.mockRejectedValueOnce(new Error('initial fail'))
.mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] })
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(screen.queryByTestId('terminal-error-message')).toBeNull()
expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull()
expect(terminalMock.write).toHaveBeenCalledWith('recovered\n')
})
})
it('cleans up listeners and unsubscribes on unmount', async () => {
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
unmount()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await nextTick()
expect(terminalMock.reset).not.toHaveBeenCalled()
// No additional getRawLogs beyond the mount-time call
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
it('does not write to the terminal when unmount happens mid-fetch', async () => {
const pending = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => pending.promise)
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
unmount()
pending.resolve({ entries: [{ m: 'late\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalled()
})
})

View File

@@ -12,36 +12,79 @@
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal
v-show="!loading && !errorMessage"
@created="terminalCreated"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />
</div>
</template>
<script setup lang="ts">
import type { Terminal } from '@xterm/xterm'
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { shallowRef } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import BaseTerminal from './BaseTerminal.vue'
const terminal = shallowRef<Terminal>()
const { errorMessage, loading } = useLogsTerminal(terminal)
const errorMessage = ref('')
const loading = ref(true)
const terminalCreated = (
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
// Auto-size terminal to fill container width.
// minCols: 80 ensures minimum width for colab environments.
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
terminal.value = instance
const update = (entries: Array<LogEntry>) => {
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
await api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
await watchLogs()
loading.value = false
})
onUnmounted(async () => {
if (api.clientId) {
await api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
})
}
</script>

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -43,43 +42,4 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -55,7 +55,7 @@
</div>
<Button
v-if="type !== 'info' && type !== 'dirtyClose'"
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
@@ -86,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ denyLabel ?? $t('g.no') }}
{{ $t('g.no') }}
</Button>
<Button autofocus @click="onConfirm">
<Button @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -131,7 +131,6 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

View File

@@ -12,7 +12,7 @@
</span>
<span
v-if="rest"
class="-ml-2.5 flex h-5 max-w-max min-w-0 grow basis-0 items-center truncate rounded-r-full bg-component-node-widget-background text-xs"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>

View File

@@ -23,7 +23,6 @@
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>

View File

@@ -1,195 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
modifiedWorkflows: [] as ModifiedWorkflow[]
}))
const mockWorkflowService = vi.hoisted(() => ({
saveWorkflow: vi.fn().mockResolvedValue(true)
}))
const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => mockWorkflowService)
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
})
}))
function makeWorkflow(path: string): ModifiedWorkflow {
return { path, isModified: true } satisfies ModifiedWorkflow
}
describe('useAuthActions.logout', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
})
it('logs out without prompting when no workflows are modified', async () => {
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).not.toHaveBeenCalled()
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when the dialog is dismissed (null)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('signs out without saving when the user picks "Sign out anyway" (false)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when saving a workflow is cancelled', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('does not log out if a workflow save fails', async () => {
mockWorkflowStore.modifiedWorkflows = [
makeWorkflow('a.json'),
makeWorkflow('b.json')
]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockRejectedValueOnce(
new Error('disk full')
)
const { logout } = useAuthActions()
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
const workflows = [makeWorkflow('a.json'), makeWorkflow('b.json')]
mockWorkflowStore.modifiedWorkflows = workflows
mockDialogService.confirm.mockResolvedValueOnce(true)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(2)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
1,
workflows[0]
)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
2,
workflows[1]
)
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1]
).toBeLessThan(mockAuthStore.logout.mock.invocationCallOrder[0])
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[0]
).toBeLessThan(mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1])
})
it('passes denyLabel "Sign out anyway" to the dialog', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dirtyClose',
title: 'auth.signOut.unsavedChangesTitle',
message: 'auth.signOut.unsavedChangesMessage',
denyLabel: 'auth.signOut.signOutAnyway'
})
)
})
})

View File

@@ -9,7 +9,6 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -54,30 +53,14 @@ export const useAuthActions = () => {
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
const modifiedWorkflows = workflowStore.modifiedWorkflows
if (modifiedWorkflows.length > 0) {
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose',
denyLabel: t('auth.signOut.signOutAnyway')
type: 'dirtyClose'
})
if (confirmed === null) return
if (confirmed === true) {
const workflowService = useWorkflowService()
for (const workflow of modifiedWorkflows) {
try {
const saved = await workflowService.saveWorkflow(workflow)
if (!saved) return
} catch {
throw new Error(
t('auth.signOut.saveFailed', { workflow: workflow.path })
)
}
}
}
if (!confirmed) return
}
await authStore.logout()

View File

@@ -1,123 +0,0 @@
import { until, useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref } from 'vue'
import { onMounted, onScopeDispose, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
type TerminalLike = {
write: (data: string) => void
reset: () => void
scrollToBottom: () => void
}
/**
* Drives the built-in logs terminal: initial load, live `logs` stream, and
* full resync when the backend WebSocket reconnects (e.g., after a reboot).
*
* Listeners are registered synchronously so we cannot miss a `reconnected`
* event during the mount-time fetch/subscribe awaits. In-flight fetches are
* tied to AbortControllers so that:
* - rapid double-reconnects don't interleave writes / double-subscribe
* - unmount mid-fetch never writes to a disposed terminal
*/
export function useLogsTerminal(
terminal: Readonly<Ref<TerminalLike | undefined>>
) {
const { t } = useI18n()
const errorMessage = ref('')
const loading = ref(true)
let mountController: AbortController | undefined
let resyncController: AbortController | undefined
const writeEntries = (entries: LogEntry[]) => {
terminal.value?.write(entries.map((e) => e.m).join(''))
}
const resyncLogs = async () => {
// Cancel both the in-flight mount fetch and any prior resync so a late
// mount response can't write a stale snapshot on top of a freshly-reset
// terminal after we've already written the post-reconnect view.
mountController?.abort()
resyncController?.abort()
const controller = new AbortController()
resyncController = controller
const { signal } = controller
try {
const logs = await api.getRawLogs()
if (signal.aborted || !terminal.value) return
terminal.value.reset()
writeEntries(logs.entries)
terminal.value.scrollToBottom()
// Backend lost the per-client log subscription across the restart;
// re-subscribe so new runtime logs stream over the fresh WebSocket.
await api.subscribeLogs(true)
if (signal.aborted) return
errorMessage.value = ''
loading.value = false
} catch (err) {
if (signal.aborted) return
console.error('Error resyncing logs after reconnect', err)
errorMessage.value = t('logsTerminal.resyncError')
}
}
// Register listeners synchronously, before any awaits, so a reconnect
// fired during mount cannot be missed. useEventListener handles cleanup
// on scope dispose.
useEventListener(api, 'logs', (e: CustomEvent<LogsWsMessage>) => {
writeEntries(e.detail.entries)
})
useEventListener(api, 'reconnected', () => {
void resyncLogs()
})
onMounted(async () => {
if (!terminal.value) await until(terminal).toBeTruthy()
const controller = new AbortController()
mountController = controller
const { signal } = controller
try {
const logs = await api.getRawLogs()
if (signal.aborted || !terminal.value) return
writeEntries(logs.entries)
} catch (err) {
if (signal.aborted) return
console.error('Error loading logs', err)
errorMessage.value = t('logsTerminal.loadError')
loading.value = false
return
}
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) await until(clientId).not.toBeNull()
if (signal.aborted) return
try {
await api.subscribeLogs(true)
} catch (err) {
if (signal.aborted) return
console.error('Error subscribing to logs', err)
}
if (!signal.aborted) loading.value = false
})
onScopeDispose(() => {
mountController?.abort()
resyncController?.abort()
if (!api.clientId) return
api.subscribeLogs(false).catch((err) => {
console.error('Error unsubscribing from logs', err)
})
})
return { errorMessage, loading }
}

View File

@@ -48,8 +48,6 @@ export interface WidgetSlotMetadata {
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
@@ -109,7 +107,7 @@ export interface VueNodeData {
title: string
type: string
apiNode?: boolean
badges?: Badges
badges?: (LGraphBadge | (() => LGraphBadge))[]
bgcolor?: string
color?: string
flags?: {
@@ -788,12 +786,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
showAdvanced: Boolean(propertyEvent.newValue)
})
break
case 'badges':
vueNodeData.set(nodeId, {
...currentData,
badges: propertyEvent.newValue as Badges
})
break
}
}
},

View File

@@ -625,9 +625,9 @@ describe('useNodePricing', () => {
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
// VueNodes path bumps per-node ref and the global tick.
// VueNodes path bumps per-node ref instead of the global tick.
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
expect(pricingRevision.value).toBe(tickBefore)
} finally {
LiteGraph.vueNodesMode = false
}

View File

@@ -509,8 +509,10 @@ const scheduleEvaluation = (
if (LiteGraph.vueNodesMode) {
// VueNodes mode: bump per-node revision (only this node re-renders)
getNodeRevisionRef(node.id).value++
} else {
// Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas
pricingTick.value++
}
pricingTick.value++
})
inflight.set(node, { sig, promise })

View File

@@ -18,15 +18,6 @@ export const usePriceBadge = () => {
} else {
node.badges.push(...newBadges)
}
const graph = node.graph
if (!graph) return
graph.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'badges',
oldValue: node.badges,
newValue: node.badges
})
}
function collectCreditsBadges(
graph: LGraph,

View File

@@ -1,4 +1,3 @@
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import { useExtensionService } from '@/services/extensionService'
@@ -20,7 +19,6 @@ useExtensionService().registerExtension({
},
onAuthUserLogout: async () => {
clearOAuthRequestId()
const { deleteSession } = useSessionCookie()
await deleteSession()
}

View File

@@ -3,7 +3,6 @@ import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -3295,15 +3294,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (result != null) this.dirty_canvas = result
}
}
const firstLink: RenderLink | undefined = linkConnector.renderLinks.at(0)
const isSubgraphIOLink =
linkConnector.isConnecting && firstLink?.isIoNodeLink
// get node over
const node =
LiteGraph.vueNodesMode && !isSubgraphIOLink
? null
: graph.getNodeOnPos(x, y, this.visible_nodes)
const node = LiteGraph.vueNodesMode
? null
: graph.getNodeOnPos(x, y, this.visible_nodes)
const dragRect = this.dragging_rectangle
if (dragRect) {
@@ -3394,6 +3389,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Check if link is over anything it could connect to - record position of valid target for snap / highlight
if (linkConnector.isConnecting) {
const firstLink = linkConnector.renderLinks.at(0)
// Default: nothing highlighted
let highlightPos: Point | undefined
let highlightInput: INodeInputSlot | undefined
@@ -3444,7 +3441,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
highlightInput = node.inputs[inputId]
}
if (highlightInput && !LiteGraph.vueNodesMode) {
if (highlightInput) {
const widget = node.getWidgetFromSlot(highlightInput)
if (widget) linkConnector.overWidget = widget
}

View File

@@ -43,8 +43,6 @@ export interface RenderLink {
/** The reroute that the link is being connected from. */
readonly fromReroute?: Reroute
readonly isIoNodeLink?: boolean
/**
* Capability checks used for hit-testing and validation during drag.
* Implementations should return `false` when a connection is not possible

View File

@@ -24,7 +24,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
readonly fromPos: Point
fromDirection: LinkDirection = LinkDirection.RIGHT
readonly existingLink?: LLink
readonly isIoNodeLink = true
constructor(
readonly network: LinkNetwork,

View File

@@ -23,7 +23,6 @@ export class ToOutputFromIoNodeLink implements RenderLink {
readonly fromPos: Point
readonly fromSlotIndex: SlotIndex
fromDirection: LinkDirection = LinkDirection.LEFT
readonly isIoNodeLink = true
constructor(
readonly network: LinkNetwork,

View File

@@ -136,13 +136,6 @@ export class SubgraphInput extends SubgraphSlot {
}
subgraph.incrementVersion()
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
subgraph.afterChange()
@@ -246,8 +239,11 @@ export class SubgraphInput extends SubgraphSlot {
override isValidTarget(
fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
): boolean {
if (isNodeSlot(fromSlot) && 'link' in fromSlot) {
return LiteGraph.isValidConnection(this.type, fromSlot.type)
if (isNodeSlot(fromSlot)) {
return (
'link' in fromSlot &&
LiteGraph.isValidConnection(this.type, fromSlot.type)
)
}
if (isSubgraphOutput(fromSlot)) {

View File

@@ -226,13 +226,6 @@ export class SubgraphInputNode
link,
subgraphInput
)
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: slotIndex,
connected: false,
linkId: link.id
})
}
}

View File

@@ -140,8 +140,11 @@ export class SubgraphOutput extends SubgraphSlot {
override isValidTarget(
fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
): boolean {
if (isNodeSlot(fromSlot) && 'links' in fromSlot) {
return LiteGraph.isValidConnection(fromSlot.type, this.type)
if (isNodeSlot(fromSlot)) {
return (
'links' in fromSlot &&
LiteGraph.isValidConnection(fromSlot.type, this.type)
)
}
if (isSubgraphInput(fromSlot)) {

View File

@@ -979,7 +979,6 @@
"dirtyCloseTitle": "Save Changes?",
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
"dirtyCloseHint": "Hold Shift to close without prompt",
"dirtyCloseAnyway": "Close anyway",
"confirmOverwriteTitle": "Overwrite existing file?",
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
"workflowTreeType": {
@@ -1159,10 +1158,6 @@
"saveAsTemplate": "Save as template",
"enterName": "Enter name"
},
"logsTerminal": {
"loadError": "Unable to load logs, please ensure you have updated your ComfyUI backend.",
"resyncError": "Unable to resync logs after the backend reconnected. Reopen the console to retry."
},
"workflowService": {
"exportWorkflow": "Export Workflow",
"enterFilename": "Enter the filename",
@@ -2133,43 +2128,6 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"oauth": {
"consent": {
"allow": "Continue",
"deny": "Cancel",
"genericError": "OAuth request failed. Please restart from the client app.",
"loading": "Loading authorization request…",
"missingRequest": "This authorization request is missing. Please restart from the client app.",
"noWorkspaces": "No eligible workspaces are available for this request.",
"title": "{client} wants access",
"subtitle": "Sign in to {resource} to continue",
"resourceFallback": "this app",
"workspaceLabel": "Workspace",
"permissionsHeader": "Permissions",
"workspaceHelp": "Permissions apply to this workspace only.",
"redirectNotice": "You'll be redirected to",
"appTypeNative": "Native app",
"appTypeWeb": "Web app",
"errorExpired": "This consent request has expired or has already been used. Please restart from the client app.",
"errorScopeBroadening": "The previously approved permissions don't cover this request. You'll need to re-authorize with the new permissions.",
"errorUnavailable": "This feature isn't available right now. Please contact support if the problem persists.",
"sessionError": "Failed to establish session. Please try again.",
"sessionErrorToastSummary": "Couldn't continue OAuth sign-in"
},
"scopes": {
"mcp:tools:read": {
"label": "View available workflow tools"
},
"mcp:tools:call": {
"label": "Run workflows on your behalf"
}
},
"workspace": {
"personal": "Personal",
"owner": "Owner",
"member": "Member"
}
},
"auth": {
"apiKey": {
"title": "API Key",
@@ -2252,9 +2210,7 @@
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?",
"signOutAnyway": "Sign out anyway",
"saveFailed": "Sign-out cancelled because saving \"{workflow}\" failed."
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
},
"passwordUpdate": {
"success": "Password Updated",

View File

@@ -1,91 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetIdToken = vi.fn()
const originalFetch = globalThis.fetch
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
teamWorkspacesEnabled: true
}
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken,
getAuthHeader: vi.fn()
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
describe('useSessionCookie', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
mockGetIdToken.mockReset()
globalThis.fetch = vi.fn()
})
afterEach(() => {
// Restore the global fetch so a leaked mock doesn't bleed into later
// tests that depend on real fetch semantics.
globalThis.fetch = originalFetch
})
it('createSessionOrThrow posts the Firebase token and awaits success', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(null, { status: 204 })
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await useSessionCookie().createSessionOrThrow()
expect(globalThis.fetch).toHaveBeenCalledWith('/api/auth/session', {
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
}
})
})
it('createSessionOrThrow fails fast without a Firebase token', async () => {
mockGetIdToken.mockResolvedValue(null)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
'No Firebase token available for session creation'
)
expect(globalThis.fetch).not.toHaveBeenCalled()
})
it('createSessionOrThrow fails fast on non-success responses', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ message: 'session denied' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
'session denied'
)
})
})

View File

@@ -8,36 +8,6 @@ import { useAuthStore } from '@/stores/authStore'
* Creates and deletes session cookies on the ComfyUI server.
*/
export const useSessionCookie = () => {
const createSessionWithHeader = async (
authHeader: Record<string, string>
): Promise<Response> => {
return await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
}
const readSessionError = async (response: Response): Promise<string> => {
const errorData: unknown = await response.json().catch(() => null)
const message = (errorData as { message?: unknown } | null)?.message
return typeof message === 'string' ? message : response.statusText
}
const getFirebaseSessionHeaderOrThrow = async (): Promise<
Record<string, string>
> => {
const firebaseToken = await useAuthStore().getIdToken()
if (!firebaseToken) {
throw new Error('No Firebase token available for session creation')
}
return { Authorization: `Bearer ${firebaseToken}` }
}
/**
* Creates or refreshes the session cookie.
* Called after login and on token refresh.
@@ -77,12 +47,20 @@ export const useSessionCookie = () => {
authHeader = header
}
const response = await createSessionWithHeader(authHeader)
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to create session cookie:',
await readSessionError(response)
errorData.message || response.statusText
)
}
} catch (error) {
@@ -90,16 +68,6 @@ export const useSessionCookie = () => {
}
}
const createSessionOrThrow = async (): Promise<void> => {
if (!isCloud) return
const authHeader = await getFirebaseSessionHeaderOrThrow()
const response = await createSessionWithHeader(authHeader)
if (!response.ok) {
throw new Error(await readSessionError(response))
}
}
/**
* Deletes the session cookie.
* Called on logout.
@@ -114,9 +82,10 @@ export const useSessionCookie = () => {
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to delete session cookie:',
await readSessionError(response)
errorData.message || response.statusText
)
}
} catch (error) {
@@ -126,7 +95,6 @@ export const useSessionCookie = () => {
return {
createSession,
createSessionOrThrow,
deleteSession
}
}

View File

@@ -1,98 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const baseChallenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'preview-csrf-token',
client_display_name: 'Comfy Desktop',
resource_display_name: 'Comfy Cloud',
redirect_uri: 'http://127.0.0.1:50632/cb',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Comfy Team',
type: 'team',
role: 'member'
}
]
}
const meta: Meta<typeof OAuthConsentView> = {
title: 'Cloud/OAuth/Consent',
component: OAuthConsentView
}
export default meta
type Story = StoryObj<typeof meta>
export const TwoWorkspaces: Story = {
args: { initialChallenge: baseChallenge }
}
export const SingleWorkspace: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: [baseChallenge.workspaces[0]]
}
}
}
export const ManyWorkspaces: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: [
baseChallenge.workspaces[0],
baseChallenge.workspaces[1],
{
id: 'design-team',
name: 'Design Studio',
type: 'team',
role: 'owner'
},
{
id: 'agency-team',
name: 'Agency Workspace',
type: 'team',
role: 'member'
}
]
}
}
}
export const NoWorkspaces: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: []
}
}
}
export const UnknownScope: Story = {
args: {
initialChallenge: {
...baseChallenge,
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:billing:read']
}
}
}
export const ComfyCli: Story = {
args: {
initialChallenge: {
...baseChallenge,
client_display_name: 'Comfy CLI'
}
}
}

View File

@@ -1,231 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import { OAuthApiError } from '@/platform/cloud/oauth/oauthApi'
import type * as oauthApi from '@/platform/cloud/oauth/oauthApi'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const submitOAuthConsentDecision = vi.fn()
vi.mock('@/platform/cloud/oauth/oauthApi', async () => {
const actual = await vi.importActual<typeof oauthApi>(
'@/platform/cloud/oauth/oauthApi'
)
return {
...actual,
submitOAuthConsentDecision: (
...args: Parameters<typeof actual.submitOAuthConsentDecision>
) => submitOAuthConsentDecision(...args)
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
singleSelectDropdown: 'Select an option'
},
oauth: {
consent: {
allow: 'Continue',
deny: 'Cancel',
genericError: 'OAuth request failed.',
loading: 'Loading authorization request…',
missingRequest: 'This authorization request is missing.',
noWorkspaces: 'No eligible workspaces are available.',
title: '{client} wants access',
subtitle: 'Sign in to {resource} to continue',
workspaceLabel: 'Workspace',
permissionsHeader: 'Permissions',
workspaceHelp: 'Permissions apply to this workspace only.',
redirectNotice: "You'll be redirected to",
appTypeNative: 'Native app',
appTypeWeb: 'Web app',
errorExpired:
'This consent request has expired or has already been used.',
errorScopeBroadening:
"The previously approved permissions don't cover this request.",
errorUnavailable: "This feature isn't available right now."
},
scopes: {
'mcp:tools:read': {
label: 'View available workflow tools'
},
'mcp:tools:call': {
label: 'Run workflows on your behalf'
}
},
workspace: {
personal: 'Personal',
owner: 'Owner',
member: 'Member'
}
}
}
}
})
const challenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'csrf-token',
client_display_name: 'Comfy Desktop',
resource_display_name: 'Comfy Cloud',
redirect_uri: 'http://127.0.0.1:50632/cb',
client_application_type: 'native',
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Team',
type: 'team',
role: 'member'
}
]
}
const renderConsent = (overrides: Partial<OAuthConsentChallenge> = {}) =>
render(OAuthConsentView, {
global: { plugins: [i18n] },
props: {
initialChallenge: { ...challenge, ...overrides }
}
})
describe('OAuthConsentView', () => {
beforeEach(() => {
submitOAuthConsentDecision.mockReset().mockResolvedValue(undefined)
})
it('renders title, subtitle, and scope checklist', () => {
renderConsent()
// Title is "<client> wants access". Subtitle is "Sign in to <resource>
// to continue". Both are short and avoid repeating any brand name twice.
expect(screen.getByText('Comfy Desktop wants access')).toBeVisible()
expect(screen.getByText('Sign in to Comfy Cloud to continue')).toBeVisible()
// Permissions section header is just the static word "Permissions".
expect(screen.getByText('Permissions')).toBeVisible()
// Known scopes render their human-readable labels. We deliberately
// avoid MCP jargon ("tools", "metadata") — the user thinks in
// ComfyUI vocabulary (workflows), and the consent UI doesn't show
// an enumerated tool list, so the label shouldn't promise one.
expect(screen.getByText('View available workflow tools')).toBeVisible()
expect(screen.getByText('Run workflows on your behalf')).toBeVisible()
// Unknown scopes fall back to the raw scope string so a new resource
// doesn't require a frontend release just to render its consent page.
expect(screen.getByText('mcp:unknown:test')).toBeVisible()
})
it('renders the registered redirect URI verbatim', () => {
renderConsent()
// Verbatim render — the user must be able to read the loopback URL
// and verify it's the localhost callback their CLI is listening on.
expect(screen.getByText('http://127.0.0.1:50632/cb')).toBeVisible()
expect(screen.getByText("You'll be redirected to")).toBeVisible()
})
it('preselects the only workspace and submits with it', async () => {
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
// Single-workspace path: Allow is enabled and submission carries the
// sole workspace_id.
await user.click(screen.getByRole('button', { name: 'Continue' }))
expect(submitOAuthConsentDecision).toHaveBeenCalledWith({
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'personal-workspace'
})
})
it('keeps Allow disabled when multiple workspaces are available and none is chosen', () => {
renderConsent()
const allow = screen.getByRole('button', { name: 'Continue' })
expect(allow).toBeDisabled()
})
it('submits deny when the user cancels', async () => {
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(submitOAuthConsentDecision).toHaveBeenCalledWith(
expect.objectContaining({
decision: 'deny',
workspaceId: 'personal-workspace'
})
)
})
it('disables both buttons when no workspaces are available', () => {
renderConsent({ workspaces: [] })
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
})
it('maps OAuthApiError(400) to the expired-request message', async () => {
submitOAuthConsentDecision.mockRejectedValue(
new OAuthApiError('expired', 400)
)
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText(
'This consent request has expired or has already been used.'
)
).toBeVisible()
})
})
it('maps OAuthApiError(403) to the scope-broadening re-prompt message', async () => {
submitOAuthConsentDecision.mockRejectedValue(
new OAuthApiError('scope broadening', 403)
)
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText(
"The previously approved permissions don't cover this request."
)
).toBeVisible()
})
})
it('maps OAuthApiError(404) to the feature-unavailable message', async () => {
submitOAuthConsentDecision.mockRejectedValue(
new OAuthApiError('disabled', 404)
)
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText("This feature isn't available right now.")
).toBeVisible()
})
})
})

View File

@@ -1,299 +0,0 @@
<template>
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center p-6">
<section
v-if="challenge"
class="flex flex-col gap-6 rounded-2xl border border-solid border-muted bg-secondary-background p-6 shadow-sm"
>
<header class="flex flex-col items-center gap-3 pt-2 text-center">
<div
class="flex size-12 items-center justify-center rounded-2xl bg-secondary-background"
>
<i
class="icon-[lucide--key] size-5 text-base-foreground"
aria-hidden="true"
/>
</div>
<div class="flex flex-col items-center gap-1.5">
<h1 class="m-0 text-xl/tight font-semibold">
{{
t('oauth.consent.title', {
client: challenge.client_display_name
})
}}
</h1>
<p class="m-0 text-sm text-muted">
{{ t('oauth.consent.subtitle', { resource: resourceName }) }}
</p>
</div>
</header>
<section class="flex flex-col gap-2">
<p class="m-0 text-sm font-medium">
{{ t('oauth.consent.workspaceLabel') }}
</p>
<div
v-if="challenge.workspaces.length === 0"
class="p-3 text-sm text-muted"
>
{{ t('oauth.consent.noWorkspaces') }}
</div>
<RadioGroupRoot
v-else
v-model="selectedWorkspaceId"
:aria-label="t('oauth.consent.workspaceLabel')"
class="m-0 flex scrollbar-custom max-h-72 list-none flex-col gap-1 overflow-y-auto p-0"
>
<RadioGroupItem
v-for="workspace in challenge.workspaces"
:key="workspace.id"
:value="workspace.id"
:class="
cn(
'flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-left transition-colors',
'hover:bg-secondary-background-hover',
'focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none',
selectedWorkspaceId === workspace.id &&
'bg-secondary-background'
)
"
>
<WorkspaceProfilePic
class="size-8 shrink-0 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span class="text-xs text-muted-foreground">
{{ workspaceSecondaryLabel(workspace) }}
</span>
</div>
<i
v-if="selectedWorkspaceId === workspace.id"
class="icon-[lucide--check] size-4 shrink-0 text-base-foreground"
aria-hidden="true"
/>
</RadioGroupItem>
</RadioGroupRoot>
<p class="m-0 text-xs text-muted">
{{ t('oauth.consent.workspaceHelp') }}
</p>
</section>
<section class="flex flex-col gap-3">
<p class="m-0 text-sm font-medium">
{{ t('oauth.consent.permissionsHeader') }}
</p>
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="scope in challenge.scopes"
:key="scope"
class="flex items-center gap-2"
>
<i
class="icon-[lucide--check] size-4 shrink-0 text-primary-background"
aria-hidden="true"
/>
<span class="text-sm">
{{ scopeLabel(scope) }}
</span>
</li>
</ul>
</section>
<section
v-if="challenge.redirect_uri"
class="flex flex-col gap-1.5 rounded-lg border border-solid border-muted bg-secondary-background/40 p-3"
>
<span class="text-xs text-muted">
{{ t('oauth.consent.redirectNotice') }}
</span>
<code
class="m-0 truncate font-mono text-xs text-base-foreground"
:title="challenge.redirect_uri"
>
{{ challenge.redirect_uri }}
</code>
</section>
<p
v-if="errorMessage"
role="alert"
class="m-0 rounded-md border border-solid border-destructive-background bg-destructive-background/10 p-3 text-sm text-destructive-background"
>
{{ errorMessage }}
</p>
<footer class="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
class="w-full"
:loading="submitting === 'allow'"
:disabled="isSubmitting || !selectedWorkspaceIsValid"
@click="submit('allow')"
>
{{ t('oauth.consent.allow') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
:loading="submitting === 'deny'"
:disabled="isSubmitting || challenge.workspaces.length === 0"
@click="submit('deny')"
>
{{ t('oauth.consent.deny') }}
</Button>
</footer>
</section>
<p
v-else-if="errorMessage"
role="alert"
class="m-0 rounded-md border border-solid border-destructive-background bg-destructive-background/10 p-3 text-center text-sm text-destructive-background"
>
{{ errorMessage }}
</p>
<p v-else class="m-0 text-center text-sm text-muted">
{{ t('oauth.consent.loading') }}
</p>
</main>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { RadioGroupItem, RadioGroupRoot } from 'reka-ui'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import {
OAuthApiError,
fetchOAuthConsentChallenge,
submitOAuthConsentDecision
} from '@/platform/cloud/oauth/oauthApi'
import type {
OAuthConsentChallenge,
OAuthWorkspace
} from '@/platform/cloud/oauth/oauthApi'
import {
clearOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
const { initialChallenge } = defineProps<{
initialChallenge?: OAuthConsentChallenge
}>()
const { t, te } = useI18n()
const route = useRoute()
function getDefaultWorkspaceId(
source: OAuthConsentChallenge | undefined
): string | undefined {
return source?.workspaces.length === 1 ? source.workspaces[0].id : undefined
}
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
const selectedWorkspaceId = ref<string | undefined>(
getDefaultWorkspaceId(initialChallenge)
)
const errorMessage = ref('')
const submitting = ref<'allow' | 'deny' | null>(null)
const isSubmitting = computed(() => submitting.value !== null)
const resourceName = computed(
() =>
challenge.value?.resource_display_name ??
t('oauth.consent.resourceFallback')
)
const selectedWorkspaceIsValid = computed(() =>
Boolean(
selectedWorkspaceId.value &&
challenge.value?.workspaces.some(
(workspace) => workspace.id === selectedWorkspaceId.value
)
)
)
function scopeLabel(scope: string): string {
const key = `oauth.scopes.${scope}.label`
return te(key) ? t(key) : scope
}
// Row's secondary label: personal workspaces show "Personal" (role is
// always implicit owner); team workspaces show the role ("Owner"/"Member").
function workspaceSecondaryLabel(workspace: OAuthWorkspace): string {
if (workspace.type === 'personal') return t('oauth.workspace.personal')
return workspace.role === 'owner'
? t('oauth.workspace.owner')
: t('oauth.workspace.member')
}
function requestIdFromRoute(): string | null {
return typeof route.query.oauth_request_id === 'string'
? route.query.oauth_request_id
: getOAuthRequestId()
}
async function loadChallenge() {
const oauthRequestId = requestIdFromRoute()
if (!oauthRequestId) {
errorMessage.value = t('oauth.consent.missingRequest')
return
}
try {
const next = await fetchOAuthConsentChallenge(oauthRequestId)
challenge.value = next
selectedWorkspaceId.value = getDefaultWorkspaceId(next)
} catch (error) {
errorMessage.value = messageForError(error)
}
}
function messageForError(error: unknown): string {
if (error instanceof OAuthApiError) {
if (error.status === 400) return t('oauth.consent.errorExpired')
if (error.status === 403) return t('oauth.consent.errorScopeBroadening')
if (error.status === 404) return t('oauth.consent.errorUnavailable')
}
return t('oauth.consent.genericError')
}
async function submit(decision: 'allow' | 'deny') {
if (!challenge.value) return
if (decision === 'allow' && !selectedWorkspaceIsValid.value) return
// Cloud requires workspace_id on both allow and deny. A deny with no
// workspaces is disabled in the template, so a workspace is guaranteed.
const workspaceId =
selectedWorkspaceId.value ?? challenge.value.workspaces[0]?.id
if (!workspaceId) return
errorMessage.value = ''
submitting.value = decision
try {
await submitOAuthConsentDecision({
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
workspaceId
})
clearOAuthRequestId()
} catch (error) {
errorMessage.value = messageForError(error)
} finally {
submitting.value = null
}
}
onMounted(() => {
if (!initialChallenge) {
void loadChallenge()
}
})
</script>

View File

@@ -1,260 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
OAuthApiError,
fetchOAuthConsentChallenge,
submitOAuthConsentDecision
} from '@/platform/cloud/oauth/oauthApi'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const validChallenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'csrf-token',
client_display_name: 'Cursor',
scopes: ['mcp:tools:read'],
workspaces: [
{
id: 'personal-workspace',
name: 'Kishore',
type: 'personal',
role: 'owner'
}
]
}
const okResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
const errorResponse = (status: number, message: string) =>
new Response(JSON.stringify({ message }), {
status,
headers: { 'Content-Type': 'application/json' }
})
describe('fetchOAuthConsentChallenge', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns the parsed challenge on 200', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(validChallenge))
const result = await fetchOAuthConsentChallenge(
validChallenge.oauth_request_id
)
expect(result).toEqual(validChallenge)
})
it('URL-encodes the oauth_request_id', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(okResponse(validChallenge))
// Reserved characters get percent-encoded (defense-in-depth — valid UUIDs
// never contain these chars, but the call should be safe regardless).
await fetchOAuthConsentChallenge('id with spaces&injected=evil')
const url = fetchSpy.mock.calls[0]?.[0] as string
expect(url).toContain(
'oauth_request_id=id%20with%20spaces%26injected%3Devil'
)
})
it('throws OAuthApiError with status on non-2xx', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
errorResponse(400, 'expired')
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toMatchObject({
name: 'OAuthApiError',
status: 400
})
})
it('rejects when scopes are not strings', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ ...validChallenge, scopes: [123, 'mcp:tools:read'] })
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when a workspace is missing required fields', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
...validChallenge,
workspaces: [{ id: 'x', name: 'y', type: 'personal' }]
})
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when workspace.type is not personal or team', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
...validChallenge,
workspaces: [{ id: 'x', name: 'y', type: 'enterprise', role: 'owner' }]
})
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when workspace.role is not owner or member', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
...validChallenge,
workspaces: [{ id: 'x', name: 'y', type: 'team', role: 'admin' }]
})
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when top-level fields are missing', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ ...validChallenge, csrf_token: undefined })
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects null body', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(null))
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
})
describe('submitOAuthConsentDecision', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('navigates to the redirect_url returned by cloud on success', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ redirect_url: 'http://127.0.0.1:50632/cb?code=xyz' })
)
// jsdom location is not writable directly; replace href via spy.
const originalLocation = globalThis.location
const hrefSetter = vi.fn()
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new Proxy(originalLocation, {
set(_target, prop, value) {
if (prop === 'href') {
hrefSetter(value)
return true
}
return Reflect.set(originalLocation, prop, value)
},
get(_target, prop) {
return Reflect.get(originalLocation, prop)
}
})
})
try {
await submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
expect(hrefSetter).toHaveBeenCalledWith(
'http://127.0.0.1:50632/cb?code=xyz'
)
} finally {
// Restore unconditionally so an assertion failure doesn't leak the
// Proxy'd location into later tests.
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: originalLocation
})
}
})
it('throws OAuthApiError on non-2xx', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
errorResponse(403, 'scope broadening')
)
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
).rejects.toBeInstanceOf(OAuthApiError)
})
it('throws when redirect_url is missing from a successful response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse({}))
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
).rejects.toThrow('redirect_url')
})
it('rejects an unsafe redirect_url scheme', async () => {
// Defense in depth: even though the cloud backend is trusted, never
// hand the browser off to a non-http(s) URL.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ redirect_url: 'javascript:alert(1)' })
)
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
).rejects.toThrow('unsafe scheme')
})
it('sends the expected JSON body', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(okResponse({ redirect_url: 'http://x.test/' }))
await submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'deny',
workspaceId: 'personal-workspace'
})
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit
expect(JSON.parse(init.body as string)).toEqual({
oauth_request_id: validChallenge.oauth_request_id,
csrf_token: validChallenge.csrf_token,
decision: 'deny',
workspace_id: 'personal-workspace'
})
})
})

View File

@@ -1,156 +0,0 @@
// All OAuth calls are relative-URL (same-origin) on purpose. useSessionCookie
// POSTs /api/auth/session through the Vite dev-server proxy (or the production
// same-host ingress), so the Set-Cookie response lands on the FE origin. A
// cross-origin fetch to a different cloud host wouldn't include that cookie,
// so the consent challenge would 302 to login (and trip browser cross-origin
// redirect rules to boot — the symptom looks like "CORS error" on a fetch
// initiated from /oauth/authorize). The Vite proxy / production ingress is
// the single point of routing.
export type OAuthWorkspace = {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}
export type OAuthConsentChallenge = {
oauth_request_id: string
csrf_token: string
client_display_name: string
resource_display_name?: string
/**
* Exact registered redirect URI the OAuth client will be sent to on
* success/deny. Surfaced verbatim so users can verify the destination
* (RFC 8252 loopback for CLIs, HTTPS for web clients).
*/
redirect_uri?: string
/**
* RFC 7591 application_type — "native" (CLI/desktop, loopback redirect)
* or "web" (HTTPS-hosted). Absent for legacy seeded clients. Used to render
* a Native / Web badge so users know what kind of app they're authorizing.
*/
client_application_type?: 'native' | 'web'
scopes: string[]
workspaces: OAuthWorkspace[]
}
export type OAuthConsentDecisionParams = {
oauthRequestId: string
csrfToken: string
decision: 'allow' | 'deny'
workspaceId: string
}
export type OAuthConsentDecision = (
params: OAuthConsentDecisionParams
) => Promise<void>
export class OAuthApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'OAuthApiError'
}
}
async function readErrorMessage(response: Response): Promise<string> {
const body: unknown = await response.json().catch(() => null)
const message = (body as { message?: unknown } | null)?.message
return typeof message === 'string' ? message : response.statusText
}
function assertChallenge(
value: unknown
): asserts value is OAuthConsentChallenge {
if (typeof value !== 'object' || value === null) {
throw new Error('OAuth consent challenge is invalid')
}
const challenge = value as Partial<OAuthConsentChallenge>
if (
typeof challenge.oauth_request_id !== 'string' ||
typeof challenge.csrf_token !== 'string' ||
typeof challenge.client_display_name !== 'string' ||
!Array.isArray(challenge.scopes) ||
!challenge.scopes.every((scope) => typeof scope === 'string') ||
!Array.isArray(challenge.workspaces) ||
!challenge.workspaces.every(isValidWorkspace)
) {
throw new Error('OAuth consent challenge is invalid')
}
}
function isValidWorkspace(value: unknown): value is OAuthWorkspace {
if (typeof value !== 'object' || value === null) return false
const workspace = value as Partial<OAuthWorkspace>
return (
typeof workspace.id === 'string' &&
typeof workspace.name === 'string' &&
(workspace.type === 'personal' || workspace.type === 'team') &&
(workspace.role === 'owner' || workspace.role === 'member')
)
}
export async function fetchOAuthConsentChallenge(
oauthRequestId: string
): Promise<OAuthConsentChallenge> {
const response = await fetch(
`/oauth/authorize?oauth_request_id=${encodeURIComponent(oauthRequestId)}`,
{
method: 'GET',
credentials: 'include'
}
)
if (!response.ok) {
throw new OAuthApiError(await readErrorMessage(response), response.status)
}
const challenge: unknown = await response.json()
assertChallenge(challenge)
return challenge
}
export async function submitOAuthConsentDecision({
oauthRequestId,
csrfToken,
decision,
workspaceId
}: OAuthConsentDecisionParams): Promise<void> {
const response = await fetch('/oauth/authorize', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
oauth_request_id: oauthRequestId,
csrf_token: csrfToken,
decision,
workspace_id: workspaceId
})
})
if (!response.ok) {
throw new OAuthApiError(await readErrorMessage(response), response.status)
}
const body: unknown = await response.json()
const redirectUrl = (body as { redirect_url?: unknown } | null)?.redirect_url
if (typeof redirectUrl !== 'string') {
throw new Error('OAuth consent response did not include redirect_url')
}
// Defense in depth: even though the cloud backend is trusted, never hand
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
// execute in our origin.
const target = new URL(redirectUrl, globalThis.location.origin)
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('OAuth consent redirect_url has an unsafe scheme')
}
globalThis.location.href = redirectUrl
}

View File

@@ -1,91 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
captureOAuthRequestId,
clearOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
describe('oauthState', () => {
beforeEach(() => {
sessionStorage.clear()
clearOAuthRequestId()
})
it('captures a valid oauth_request_id only', () => {
captureOAuthRequestId({
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
client_id: 'must-not-be-stored'
})
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBe(
'550e8400-e29b-41d4-a716-446655440000'
)
})
it('ignores missing, repeated, and invalid request ids', () => {
captureOAuthRequestId({})
expect(getOAuthRequestId()).toBeNull()
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
expect(getOAuthRequestId()).toBeNull()
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
expect(getOAuthRequestId()).toBeNull()
})
it('preserves a stored id when the query has no oauth_request_id key', () => {
// The router guard runs on every navigation, including the OAuth
// return-trip from a social-login provider (Google / GitHub) which
// arrives at /login with `code` + `state` but no oauth_request_id.
// The previously-captured id MUST survive that hop.
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
captureOAuthRequestId({ code: 'oauth-provider-code', state: 'xyz' })
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
})
it('clears a stored id when the query has an invalid oauth_request_id', () => {
// Stale deep-link or probing — drop the stored value rather than let
// it steer later flows into an expired consent request.
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
expect(getOAuthRequestId()).toBeNull()
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
})
it('clears a stored id when the query has a repeated oauth_request_id', () => {
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
expect(getOAuthRequestId()).toBeNull()
})
it('hydrates from session storage and clears after completion', () => {
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
clearOAuthRequestId()
expect(getOAuthRequestId()).toBeNull()
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
})
})

View File

@@ -1,50 +0,0 @@
import type { LocationQuery } from 'vue-router'
const OAUTH_REQUEST_ID_STORAGE_KEY = 'Comfy.OAuthRequestId'
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function readQueryString(value: LocationQuery[string]): string | null {
return typeof value === 'string' ? value : null
}
function isOAuthRequestId(value: string): boolean {
return UUID_PATTERN.test(value)
}
export function captureOAuthRequestId(query: LocationQuery): string | null {
// The router guard calls this on every navigation. We can't unconditionally
// clear on absence — the OAuth return-trip from a social-login provider
// (Google / GitHub) arrives at /login with `code` + `state` in the query
// but no `oauth_request_id`, and we need the previously-captured value to
// survive that hop.
//
// We DO clear on an explicitly invalid value (present but malformed): that
// shape is either a stale deep-link or probing, and a stale Comfy.OAuthRequestId
// contaminating later flows is worse than dropping the bad input.
const raw = query.oauth_request_id
const value = readQueryString(raw)
if (!value) {
if (raw !== undefined) {
// Present but non-string (e.g. repeated `?oauth_request_id=a&oauth_request_id=b`).
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
}
return null
}
if (!isOAuthRequestId(value)) {
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
return null
}
sessionStorage.setItem(OAUTH_REQUEST_ID_STORAGE_KEY, value)
return value
}
export function getOAuthRequestId(): string | null {
const value = sessionStorage.getItem(OAUTH_REQUEST_ID_STORAGE_KEY)
return value && isOAuthRequestId(value) ? value : null
}
export function clearOAuthRequestId(): void {
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
}

View File

@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
const routerPush = vi.fn().mockResolvedValue(undefined)
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush })
}))
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
useSessionCookie: () => ({ createSessionOrThrow })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function mountRedirect() {
let api: ReturnType<typeof useOAuthPostLoginRedirect> | undefined
const Child = defineComponent({
setup() {
api = useOAuthPostLoginRedirect()
return () => null
}
})
const host = document.createElement('div')
const app = createApp(defineComponent({ setup: () => () => h(Child) }))
app.use(i18n)
app.mount(host)
if (!api) throw new Error('useOAuthPostLoginRedirect was not initialized')
return { api, unmount: () => app.unmount() }
}
describe('useOAuthPostLoginRedirect', () => {
beforeEach(() => {
sessionStorage.clear()
routerPush.mockClear()
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
})
it('returns no-oauth when neither query nor sessionStorage holds a request id', async () => {
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({})
expect(result).toEqual({ kind: 'no-oauth' })
expect(createSessionOrThrow).not.toHaveBeenCalled()
expect(routerPush).not.toHaveBeenCalled()
})
it('establishes session and navigates to consent when oauth_request_id is in the query', async () => {
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({
oauth_request_id: VALID_REQUEST_ID
})
expect(createSessionOrThrow).toHaveBeenCalledOnce()
expect(routerPush).toHaveBeenCalledWith({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
expect(result).toEqual({ kind: 'resumed' })
})
it('resumes using a stashed sessionStorage id when the query is empty (multi-step flows)', async () => {
sessionStorage.setItem('Comfy.OAuthRequestId', VALID_REQUEST_ID)
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({})
expect(result).toEqual({ kind: 'resumed' })
expect(routerPush).toHaveBeenCalledWith({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
})
it('returns an error with the thrown message when session creation fails', async () => {
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({
oauth_request_id: VALID_REQUEST_ID
})
expect(result).toEqual({ kind: 'error', message: 'Unauthorized' })
expect(routerPush).not.toHaveBeenCalled()
})
it('falls back to the i18n key when session creation rejects with a non-Error value', async () => {
createSessionOrThrow.mockRejectedValue('boom')
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({
oauth_request_id: VALID_REQUEST_ID
})
// Empty messages → useI18n returns the key itself, which is what we
// assert on (per docs/testing/vitest-patterns.md).
expect(result).toEqual({
kind: 'error',
message: 'oauth.consent.sessionError'
})
expect(routerPush).not.toHaveBeenCalled()
})
})

View File

@@ -1,53 +0,0 @@
import { useI18n } from 'vue-i18n'
import type { LocationQuery } from 'vue-router'
import { useRouter } from 'vue-router'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
type OAuthResumeResult =
| { kind: 'no-oauth' }
| { kind: 'resumed' }
| { kind: 'error'; message: string }
/**
* Post-login OAuth resume. If the current login flow originated from an OAuth
* authorize request, establishes the Cloud session cookie and navigates to the
* consent route. Used by both `CloudLoginView` and `CloudSignupView`.
*/
export function useOAuthPostLoginRedirect() {
const router = useRouter()
const sessionCookie = useSessionCookie()
const { t } = useI18n()
async function resumeOAuthIfNeeded(
query: LocationQuery
): Promise<OAuthResumeResult> {
captureOAuthRequestId(query)
const oauthRequestId = getOAuthRequestId()
if (!oauthRequestId) return { kind: 'no-oauth' }
try {
await sessionCookie.createSessionOrThrow()
} catch (error) {
return {
kind: 'error',
message:
error instanceof Error
? error.message
: t('oauth.consent.sessionError')
}
}
await router.push({
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
})
return { kind: 'resumed' }
}
return { resumeOAuthIfNeeded }
}

View File

@@ -118,7 +118,8 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
@@ -128,14 +129,10 @@ const route = useRoute()
const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const { onAuthSuccess } = usePostAuthRedirect({
authError,
successSummary: 'Login Completed',
defaultRedirect: () => ({ name: 'cloud-user-check' })
})
function switchToEmailForm() {
showEmailForm.value = true
@@ -149,24 +146,40 @@ const navigateToSignup = async () => {
await router.push({ name: 'cloud-signup', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Login Completed',
life: 2000
})
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)
return
}
await router.push({ name: 'cloud-user-check' })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
await onAuthSuccess()
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
await onAuthSuccess()
await onSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
authError.value = ''
if (await authActions.signInWithEmail(values.email, values.password)) {
await onAuthSuccess()
await onSuccess()
}
}
</script>

View File

@@ -142,9 +142,10 @@ import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
@@ -156,6 +157,7 @@ const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()
const {
showEmailForm,
@@ -165,34 +167,46 @@ const {
switchToSocialLogin
} = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const { onAuthSuccess } = usePostAuthRedirect({
authError,
successSummary: 'Sign up Completed',
defaultRedirect: () => ({ path: '/', query: route.query })
})
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Sign up Completed',
life: 2000
})
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)
return
}
// Default redirect to the normal onboarding flow
await router.push({ path: '/', query: route.query })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle({ isNewUser: true })) {
await onAuthSuccess()
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub({ isNewUser: true })) {
await onAuthSuccess()
await onSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
authError.value = ''
if (await authActions.signUpWithEmail(values.email, values.password)) {
await onAuthSuccess()
await onSuccess()
}
}

View File

@@ -1,58 +0,0 @@
import type { Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
/**
* Shared post-authentication redirect logic used by both CloudLoginView and
* CloudSignupView. Handles OAuth resume, previousFullPath redirect, and
* default redirect after successful sign-in or sign-up.
*/
export function usePostAuthRedirect(options: {
authError: Ref<string>
successSummary: string
defaultRedirect: () => RouteLocationRaw
}) {
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const toastStore = useToastStore()
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
async function onAuthSuccess() {
toastStore.add({
severity: 'success',
summary: options.successSummary,
life: 2000
})
const oauthResume = await resumeOAuthIfNeeded(route.query)
if (oauthResume.kind === 'error') {
// authError renders only in email-form mode; surface the failure via
// a toast so social-login users (Google / GitHub) can see it too.
options.authError.value = oauthResume.message
toastStore.add({
severity: 'error',
summary: t('oauth.consent.sessionErrorToastSummary'),
detail: oauthResume.message,
life: 4000
})
return
}
if (oauthResume.kind === 'resumed') return
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)
return
}
await router.push(options.defaultRedirect())
}
return { onAuthSuccess }
}

View File

@@ -1,20 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
// `oauth_request_id` capture lives in the global router.beforeEach guard
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
// guards read it back via getOAuthRequestId().
function oauthConsentRedirect() {
const oauthRequestId = getOAuthRequestId()
return oauthRequestId
? {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
: { name: 'cloud-user-check' }
}
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
{
path: '/cloud',
@@ -34,7 +19,9 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(oauthConsentRedirect())
// User is already logged in, redirect to user-check
// user-check will handle survey, or main page routing
return next({ name: 'cloud-user-check' })
}
}
next()
@@ -52,7 +39,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(oauthConsentRedirect())
return next({ name: 'cloud-user-check' })
}
}
next()
@@ -71,11 +58,6 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true }
},
{
path: 'oauth/consent',
name: 'cloud-oauth-consent',
component: () => import('@/platform/cloud/oauth/OAuthConsentView.vue')
},
{
path: 'user-check',
name: 'cloud-user-check',

View File

@@ -2,6 +2,5 @@ export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite',
SHARE: 'share',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth'
CREATE_WORKSPACE: 'create_workspace'
} as const

View File

@@ -418,51 +418,24 @@ describe('useWorkflowService', () => {
})
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
const result = await useWorkflowService().saveWorkflow(workflow)
await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(true)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('should return false when temporary workflow save is cancelled', async () => {
it('should call saveWorkflowAs for temporary workflows', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
const result = await useWorkflowService().saveWorkflow(workflow)
await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
beforeEach(() => {
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
it('keeps a temporary workflow open when Save As is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
workflow.isModified = true
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
mockConfirm.mockResolvedValue(true)
const closed = await service.closeWorkflow(workflow)
expect(closed).toBe(false)
expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
})
})
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let existingWorkflow: LoadedComfyWorkflow

View File

@@ -174,39 +174,40 @@ export const useWorkflowService = () => {
* Save a workflow
* @param workflow The workflow to save
*/
const saveWorkflow = async (workflow: ComfyWorkflow): Promise<boolean> => {
const saveWorkflow = async (workflow: ComfyWorkflow) => {
if (workflow.isTemporary) {
return await saveWorkflowAs(workflow)
}
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory + '/' + appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return true
await saveWorkflowAs(workflow)
} else {
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
'/' +
appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return
}
await deleteWorkflow(existing, true)
}
await deleteWorkflow(existing, true)
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
return true
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}
/**
@@ -283,15 +284,13 @@ export const useWorkflowService = () => {
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint,
denyLabel: t('sideToolbar.workflowTab.dirtyCloseAnyway')
hint: options.hint
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
const saved = await saveWorkflow(workflow)
if (!saved) return false
await saveWorkflow(workflow)
}
}

View File

@@ -76,25 +76,15 @@ vi.mock(
})
)
const commandStoreMocks = vi.hoisted(() => ({
execute: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: commandStoreMocks.execute
execute: vi.fn()
})
}))
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, unknown>
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
get query() {
return routeMocks.query
}
query: {}
}),
useRouter: () => ({
replace: vi.fn()
@@ -107,30 +97,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const preservedQueryMocks = vi.hoisted(() => ({
payloads: {} as Record<string, Record<string, string> | undefined>
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(
(namespace: string, query: Record<string, unknown> = {}) => {
const payload = preservedQueryMocks.payloads[namespace]
if (!payload) return undefined
const next: Record<string, unknown> = { ...query }
let changed = false
for (const [key, value] of Object.entries(payload)) {
if (typeof next[key] === 'string') continue
next[key] = value
changed = true
}
return changed ? next : undefined
}
)
mergePreservedQueryIntoQuery: vi.fn(() => null)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template', SHARE: 'share' }
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -205,9 +178,6 @@ describe('useWorkflowPersistenceV2', () => {
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
})
afterEach(() => {
@@ -387,43 +357,4 @@ describe('useWorkflowPersistenceV2', () => {
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('loadDefaultWorkflow', () => {
it('opens templates browser for first-time users', async () => {
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share param is in URL', async () => {
routeMocks.query = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share intent is preserved across /user-select redirect', async () => {
// No-local-user flow: ?share=... was captured into sessionStorage and the
// URL query was dropped during the /user-select redirect before
// initializeWorkflow() runs.
preservedQueryMocks.payloads.share = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
})

View File

@@ -48,7 +48,6 @@ export function useWorkflowPersistenceV2() {
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
@@ -161,20 +160,11 @@ export function useWorkflowPersistenceV2() {
})
}
const hasSharedWorkflowIntent = () => {
if (typeof route.query.share === 'string') return true
hydratePreservedQuery(SHARE_NAMESPACE)
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
return typeof merged?.share === 'string'
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
await useCommandStore().execute('Comfy.BrowseTemplates')
} else {
await comfyApp.loadGraphData()
}

View File

@@ -54,30 +54,15 @@ describe('getWidgetIdentity', () => {
expect(renderKey).toBe(dedupeIdentity)
})
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
it('returns transient renderKey for widgets without stable identity', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey when no nodeId is available at all', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
@@ -375,46 +360,6 @@ describe('computeProcessedWidgets borderStyle', () => {
expect(result).toHaveLength(1)
expect(result[0].hidden).toBe(false)
})
it('collapses duplicate normal widgets on the same node to one render', () => {
const colorA = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const colorB = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'ColorToRGBInt',
widgets: [colorA, colorB],
title: 'Color to RGB Int',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('color')
expect(result[0].renderKey).toBe('node:1:color:color:color')
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {

View File

@@ -129,15 +129,11 @@ export function getWidgetIdentity(
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const hostNodeIdRoot =
nodeId !== undefined && nodeId !== ''
? `node:${String(stripGraphPrefix(nodeId))}`
: undefined
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`

View File

@@ -411,20 +411,12 @@ export function useSlotLinkInteraction({
}
const raf = createRafBatch(processPointerMoveFrame)
const canvas = app.canvas
const node = canvas.graph?.getNodeById(nodeId)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.stopPropagation()
autoPan?.updatePointer(event.clientX, event.clientY)
if (canvas.subgraph && node) {
augmentToCanvasPointerEvent(event, node, canvas)
canvas.subgraph.inputNode.onPointerMove(event)
canvas.subgraph.outputNode.onPointerMove(event)
}
dragContext.pendingPointerMove = {
clientX: event.clientX,
clientY: event.clientY,

View File

@@ -1,76 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IColorWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
function createMockNode(): LGraphNode {
const widgets: IColorWidget[] = []
const addWidget = vi.fn(
(
type: string,
name: string,
value: string,
_callback: () => void,
options: IWidgetOptions
) => {
const widget = {
type,
name,
value,
options,
callback: _callback
} as unknown as IColorWidget
widgets.push(widget)
return widget
}
)
return { widgets, addWidget } as unknown as LGraphNode
}
const colorSpec: InputSpec = {
type: 'COLOR',
name: 'color',
default: '#ffffff',
socketless: true
}
describe('useColorWidget', () => {
it('reads the top-level default from the V2 spec', () => {
const node = createMockNode()
const widget = useColorWidget()(node, colorSpec)
expect(widget.value).toBe('#ffffff')
})
it('falls back to nested options.default when top-level default is absent', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color',
options: { default: '#abcdef' }
} as InputSpec)
expect(widget.value).toBe('#abcdef')
})
it('falls back to #000000 when no default is declared', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color'
} as InputSpec)
expect(widget.value).toBe('#000000')
})
it('returns the existing widget instead of creating a duplicate', () => {
const node = createMockNode()
const first = useColorWidget()(node, colorSpec)
const second = useColorWidget()(node, colorSpec)
expect(second).toBe(first)
expect(node.widgets).toHaveLength(1)
})
})

View File

@@ -8,14 +8,8 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const colorSpec = inputSpec as ColorInputSpec
const { name, options } = colorSpec
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
const existing = node.widgets?.find(
(w): w is IColorWidget => w.name === name && w.type === 'color'
)
if (existing) return existing
const { name, options } = inputSpec as ColorInputSpec
const defaultValue = options?.default || '#000000'
const widget = node.addWidget('color', name, defaultValue, () => {}, {
serialize: true

View File

@@ -15,7 +15,6 @@ import { useAuthStore } from '@/stores/authStore'
import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
@@ -111,18 +110,9 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.CREATE_WORKSPACE,
keys: ['create_workspace']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.OAUTH,
keys: ['oauth_request_id']
}
])
router.beforeEach((to, _from, next) => {
captureOAuthRequestId(to.query)
next()
})
router.afterEach(() => {
trackPageView()
})
@@ -133,14 +123,12 @@ if (isCloud) {
'cloud-login',
'cloud-signup',
'cloud-forgot-password',
'cloud-oauth-consent',
'cloud-sorry-contact-support'
])
const PUBLIC_ROUTE_PATHS = new Set([
'/cloud/login',
'/cloud/signup',
'/cloud/forgot-password',
'/cloud/oauth/consent',
'/cloud/sorry-contact-support'
])

View File

@@ -42,31 +42,6 @@ export type ConfirmationDialogType =
| 'reinstall'
| 'info'
interface BaseConfirmOptions {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}
type ConfirmOptions = BaseConfirmOptions &
(
| {
/** Pre-configured dialog type */
type: 'dirtyClose'
/** Override the deny button label. Defaults to `g.no`. */
denyLabel?: string
}
| {
/** Pre-configured dialog type */
type?: Exclude<ConfirmationDialogType, 'dirtyClose'>
denyLabel?: never
}
)
/**
* Minimal interface for execution error dialogs.
* Satisfied by both ExecutionErrorWsMessage (WebSocket) and ExecutionError (Jobs API).
@@ -269,9 +244,18 @@ export const useDialogService = () => {
message,
type = 'default',
itemList = [],
hint,
denyLabel
}: ConfirmOptions): Promise<boolean | null> {
hint
}: {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Pre-configured dialog type */
type?: ConfirmationDialogType
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}): Promise<boolean | null> {
return new Promise((resolve) => {
const options: ShowDialogOptions = {
key: 'global-prompt',
@@ -282,8 +266,7 @@ export const useDialogService = () => {
type,
itemList,
onConfirm: resolve,
hint,
denyLabel
hint
},
dialogComponentProps: {
onClose: () => resolve(null)

View File

@@ -214,11 +214,6 @@ export default defineConfig({
}
},
'/oauth': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig
},
'/ws': {
target: DEV_SERVER_COMFYUI_URL,
ws: true,