mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 08:25:50 +00:00
Compare commits
17 Commits
version-bu
...
codex/back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe0538c43 | ||
|
|
08dcc96aa3 | ||
|
|
0a75aca0f3 | ||
|
|
47bbb659e6 | ||
|
|
b059c22def | ||
|
|
2806fab735 | ||
|
|
1ef579abf4 | ||
|
|
9530605c3b | ||
|
|
e6ead5631a | ||
|
|
a9b9de2b10 | ||
|
|
9be62a1845 | ||
|
|
d4d2089663 | ||
|
|
7aaade0f68 | ||
|
|
e45e249ed9 | ||
|
|
5878840f26 | ||
|
|
128ca823fd | ||
|
|
97a80cad22 |
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -214,11 +214,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
|
||||
'/oauth': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
...cloudProxyConfig
|
||||
},
|
||||
|
||||
'/ws': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user