mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
3 Commits
matt/be-34
...
bl/gtm-184
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a7c3d52d | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 |
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
offset: HEADER_OFFSET_PX,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
45
browser_tests/assets/linear-validation-warning.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,6 +34,10 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -92,13 +96,19 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,6 +172,9 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -245,7 +245,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
const assetId = String(jobId)
|
||||
openAssetsSidebar()
|
||||
await nextTick()
|
||||
await assetsStore.refreshHistoryHead()
|
||||
await assetsStore.updateHistory()
|
||||
const asset = assetsStore.historyAssets.find(
|
||||
(existingAsset) => existingAsset.id === assetId
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { st, t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -136,7 +137,8 @@ export const useAuthActions = () => {
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
amount_micros: usdToMicros(amount),
|
||||
currency: 'usd'
|
||||
currency: 'usd',
|
||||
platform_source: getCheckoutPlatformSource()
|
||||
})
|
||||
|
||||
if (!response.checkout_url) {
|
||||
|
||||
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
22
src/composables/useViewErrorsInGraph.ts
Normal file
22
src/composables/useViewErrorsInGraph.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -255,13 +255,13 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
1,
|
||||
expect.any(Function),
|
||||
200,
|
||||
{ offset: 0 }
|
||||
0
|
||||
)
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Function),
|
||||
200,
|
||||
{ offset: 200 }
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ async function fetchGeneratedHistoryAssets(
|
||||
const historyPage = await fetchHistoryPage(
|
||||
api.fetchApi.bind(api),
|
||||
HISTORY_MEDIA_ASSETS_PAGE_SIZE,
|
||||
{ offset: requestedOffset }
|
||||
requestedOffset
|
||||
)
|
||||
|
||||
signal?.throwIfAborted()
|
||||
|
||||
@@ -709,7 +709,7 @@ describe('verifyMediaCandidates', () => {
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
200,
|
||||
{ offset: 0 }
|
||||
0
|
||||
)
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: 'subfolder/photo.png [output]',
|
||||
@@ -843,13 +843,13 @@ describe('verifyMediaCandidates', () => {
|
||||
1,
|
||||
expect.any(Function),
|
||||
200,
|
||||
{ offset: 0 }
|
||||
0
|
||||
)
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Function),
|
||||
200,
|
||||
{ offset: 200 }
|
||||
200
|
||||
)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
JobsApiError,
|
||||
extractWorkflow,
|
||||
fetchHistory,
|
||||
fetchHistoryPage,
|
||||
@@ -40,8 +39,7 @@ function createMockResponse(
|
||||
offset: pagination.offset ?? 0,
|
||||
limit: pagination.limit ?? 200,
|
||||
total,
|
||||
has_more: pagination.has_more ?? false,
|
||||
next_cursor: pagination.next_cursor
|
||||
has_more: pagination.has_more ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,85 +135,23 @@ describe('fetchJobs', () => {
|
||||
expect(result[0].priority).toBe(999)
|
||||
})
|
||||
|
||||
it('propagates fetch errors', async () => {
|
||||
it('returns empty array on error', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await expect(fetchHistory(mockFetch)).rejects.toThrow('Network error')
|
||||
const result = await fetchHistory(mockFetch)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('throws a JobsApiError carrying status, body, and parsed errorCode on non-ok response', async () => {
|
||||
it('returns empty array on non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'{"code":"INVALID_CURSOR","message":"Invalid pagination cursor"}'
|
||||
)
|
||||
status: 500
|
||||
})
|
||||
|
||||
await expect(fetchHistory(mockFetch)).rejects.toBeInstanceOf(JobsApiError)
|
||||
await expect(fetchHistory(mockFetch)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
errorCode: 'INVALID_CURSOR',
|
||||
message: expect.stringContaining('INVALID_CURSOR')
|
||||
})
|
||||
})
|
||||
const result = await fetchHistory(mockFetch)
|
||||
|
||||
it('leaves errorCode undefined when the body is not the structured error shape', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: () => Promise.resolve('<html>Bad Gateway</html>')
|
||||
})
|
||||
|
||||
const err = await fetchHistory(mockFetch).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(JobsApiError)
|
||||
expect(err.errorCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves errorCode undefined for an oversized body rather than parsing it', async () => {
|
||||
const oversized = `{"code":"INVALID_CURSOR","pad":"${'x'.repeat(20_000)}"}`
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve(oversized)
|
||||
})
|
||||
|
||||
const err = await fetchHistory(mockFetch).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(JobsApiError)
|
||||
expect(err.errorCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('truncates oversized error bodies to 200 chars in the thrown message', async () => {
|
||||
const oversized = 'x'.repeat(500)
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve(oversized)
|
||||
})
|
||||
|
||||
const err = await fetchHistory(mockFetch).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(JobsApiError)
|
||||
expect(err.message.length).toBeLessThanOrEqual(
|
||||
'[Jobs API] Failed to fetch jobs: 500 '.length + 200 + 1 // +1 for the ellipsis
|
||||
)
|
||||
expect(err.message).toContain('…')
|
||||
})
|
||||
|
||||
it('parses a null next_cursor as absent', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse([createMockJob('job1', 'completed')], 1, {
|
||||
next_cursor: null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
|
||||
|
||||
expect(result.nextCursor).toBeUndefined()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('parses batch containing text-only preview outputs', async () => {
|
||||
@@ -269,7 +205,7 @@ describe('fetchJobs', () => {
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 2, { offset: 5 })
|
||||
const result = await fetchHistoryPage(mockFetch, 2, 5)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/jobs?status=completed,failed,cancelled&limit=2&offset=5'
|
||||
@@ -282,79 +218,6 @@ describe('fetchJobs', () => {
|
||||
expect(result.jobs[0].priority).toBe(5)
|
||||
expect(result.jobs[1].priority).toBe(4)
|
||||
})
|
||||
|
||||
it('sends the cursor instead of offset and returns next_cursor', async () => {
|
||||
const mockFetch = vi
|
||||
.fn<(url: string) => Promise<Response>>()
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
createMockResponse([createMockJob('job1', 'completed')], 10, {
|
||||
has_more: true,
|
||||
next_cursor: 'cursor-page-2'
|
||||
})
|
||||
),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 200, {
|
||||
after: 'cursor-page-1'
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/jobs?status=completed,failed,cancelled&limit=200&after=cursor-page-1'
|
||||
)
|
||||
expect(result.nextCursor).toBe('cursor-page-2')
|
||||
expect(result.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('uri-encodes the cursor', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(createMockResponse([]))
|
||||
})
|
||||
|
||||
await fetchHistoryPage(mockFetch, 200, { after: 'a+b/c=' })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/jobs?status=completed,failed,cancelled&limit=200&after=a%2Bb%2Fc%3D'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns next_cursor from offset-mode responses for cursor bootstrap', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse([createMockJob('job1', 'completed')], 10, {
|
||||
has_more: true,
|
||||
next_cursor: 'minted-in-offset-mode'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/jobs?status=completed,failed,cancelled&limit=200&offset=0'
|
||||
)
|
||||
expect(result.nextCursor).toBe('minted-in-offset-mode')
|
||||
})
|
||||
|
||||
it('omits nextCursor when the server does not mint one', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse([createMockJob('job1', 'completed')])
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 200, { offset: 0 })
|
||||
|
||||
expect(result.nextCursor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchQueue', () => {
|
||||
@@ -405,10 +268,12 @@ describe('fetchJobs', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates fetch errors', async () => {
|
||||
it('returns empty arrays on error', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await expect(fetchQueue(mockFetch)).rejects.toThrow('Network error')
|
||||
const result = await fetchQueue(mockFetch)
|
||||
|
||||
expect(result).toEqual({ Running: [], Pending: [] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* All distributions use the /jobs endpoint.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { JobId } from '@/schemas/apiSchema'
|
||||
@@ -20,64 +18,12 @@ import type {
|
||||
} from './jobTypes'
|
||||
import { zJobDetail, zJobsListResponse, zWorkflowContainer } from './jobTypes'
|
||||
|
||||
/**
|
||||
* Position of the page to fetch. `after` is an opaque keyset cursor from a
|
||||
* prior response's `nextCursor` and takes precedence over `offset`; `offset`
|
||||
* remains as the fallback for random access and for backends that don't mint
|
||||
* cursors.
|
||||
*/
|
||||
export type JobsPageRequest =
|
||||
| { after: string; offset?: never }
|
||||
| { offset?: number; after?: never }
|
||||
|
||||
/**
|
||||
* Non-ok response from the jobs API. Carries the HTTP status and the parsed
|
||||
* machine-readable `errorCode` (from the JSON error body) so callers can tell a
|
||||
* rejected cursor (`INVALID_CURSOR`) apart from other 400s and transient
|
||||
* failures. `errorCode` is undefined when the body isn't the structured error
|
||||
* shape (e.g. a proxy error page).
|
||||
*/
|
||||
const MAX_ERROR_BODY_LENGTH = 200
|
||||
|
||||
// Cap synchronous JSON.parse so an oversized error body can't block the UI
|
||||
// thread; the structured error is a few hundred bytes at most.
|
||||
const MAX_ERROR_PARSE_LENGTH = 10_000
|
||||
|
||||
const zJobsErrorBody = z.object({ code: z.string() })
|
||||
|
||||
function parseErrorCode(body: string): string | undefined {
|
||||
if (body.length > MAX_ERROR_PARSE_LENGTH) return undefined
|
||||
try {
|
||||
return zJobsErrorBody.safeParse(JSON.parse(body)).data?.code
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class JobsApiError extends Error {
|
||||
readonly errorCode?: string
|
||||
|
||||
constructor(
|
||||
readonly status: number,
|
||||
body: string
|
||||
) {
|
||||
const truncated =
|
||||
body.length > MAX_ERROR_BODY_LENGTH
|
||||
? `${body.slice(0, MAX_ERROR_BODY_LENGTH)}…`
|
||||
: body
|
||||
super(`[Jobs API] Failed to fetch jobs: ${status} ${truncated}`.trim())
|
||||
this.name = 'JobsApiError'
|
||||
this.errorCode = parseErrorCode(body)
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchJobsRawResult {
|
||||
jobs: RawJobListItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
export interface FetchHistoryPageResult {
|
||||
@@ -86,39 +32,43 @@ export interface FetchHistoryPageResult {
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches raw jobs from /jobs endpoint.
|
||||
* Throws on failure so callers can tell a failed page apart from an empty
|
||||
* last page (e.g. a stale cursor rejected with 400 INVALID_CURSOR).
|
||||
* Fetches raw jobs from /jobs endpoint
|
||||
* @internal
|
||||
*/
|
||||
async function fetchJobsRaw(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
statuses: JobStatus[],
|
||||
maxItems: number = 200,
|
||||
page: JobsPageRequest = {}
|
||||
offset: number = 0
|
||||
): Promise<FetchJobsRawResult> {
|
||||
const statusParam = statuses.join(',')
|
||||
const pageParam =
|
||||
page.after != null
|
||||
? `after=${encodeURIComponent(page.after)}`
|
||||
: `offset=${page.offset ?? 0}`
|
||||
const url = `/jobs?status=${statusParam}&limit=${maxItems}&${pageParam}`
|
||||
const res = await fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new JobsApiError(res.status, await res.text().catch(() => ''))
|
||||
}
|
||||
const data = zJobsListResponse.parse(await res.json())
|
||||
return {
|
||||
jobs: data.jobs,
|
||||
total: data.pagination.total,
|
||||
offset: data.pagination.offset,
|
||||
limit: data.pagination.limit,
|
||||
hasMore: data.pagination.has_more,
|
||||
nextCursor: data.pagination.next_cursor ?? undefined
|
||||
const url = `/jobs?status=${statusParam}&limit=${maxItems}&offset=${offset}`
|
||||
try {
|
||||
const res = await fetchApi(url)
|
||||
if (!res.ok) {
|
||||
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
|
||||
return {
|
||||
jobs: [],
|
||||
total: 0,
|
||||
offset,
|
||||
limit: maxItems,
|
||||
hasMore: false
|
||||
}
|
||||
}
|
||||
const data = zJobsListResponse.parse(await res.json())
|
||||
return {
|
||||
jobs: data.jobs,
|
||||
total: data.pagination.total,
|
||||
offset: data.pagination.offset,
|
||||
limit: data.pagination.limit,
|
||||
hasMore: data.pagination.has_more
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Jobs API] Error fetching jobs:', error)
|
||||
return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +98,7 @@ export async function fetchHistory(
|
||||
maxItems: number = 200,
|
||||
offset: number = 0
|
||||
): Promise<JobListItem[]> {
|
||||
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, { offset })
|
||||
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
|
||||
return jobs
|
||||
}
|
||||
|
||||
@@ -158,13 +108,13 @@ export async function fetchHistory(
|
||||
export async function fetchHistoryPage(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200,
|
||||
page: JobsPageRequest = {}
|
||||
offset: number = 0
|
||||
): Promise<FetchHistoryPageResult> {
|
||||
const result = await fetchJobsRaw(
|
||||
fetchApi,
|
||||
['completed', 'failed', 'cancelled'],
|
||||
maxItems,
|
||||
page
|
||||
offset
|
||||
)
|
||||
|
||||
// History gets priority based on total count (lower than queue)
|
||||
@@ -173,8 +123,7 @@ export async function fetchHistoryPage(
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor
|
||||
hasMore: result.hasMore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +134,12 @@ export async function fetchHistoryPage(
|
||||
export async function fetchQueue(
|
||||
fetchApi: (url: string) => Promise<Response>
|
||||
): Promise<{ Running: JobListItem[]; Pending: JobListItem[] }> {
|
||||
const { jobs } = await fetchJobsRaw(fetchApi, ['in_progress', 'pending'])
|
||||
const { jobs } = await fetchJobsRaw(
|
||||
fetchApi,
|
||||
['in_progress', 'pending'],
|
||||
200,
|
||||
0
|
||||
)
|
||||
|
||||
const running = jobs.filter((j) => j.status === 'in_progress')
|
||||
const pending = jobs.filter((j) => j.status === 'pending')
|
||||
|
||||
@@ -87,8 +87,7 @@ const zPaginationInfo = z.object({
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
total: z.number(),
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().min(1).nullish()
|
||||
has_more: z.boolean()
|
||||
})
|
||||
|
||||
export const zJobsListResponse = z.object({
|
||||
|
||||
@@ -409,6 +409,7 @@ export interface PageViewMetadata {
|
||||
}
|
||||
|
||||
export interface CheckoutAttributionMetadata {
|
||||
platform_source?: PlatformSource
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
@@ -424,6 +425,8 @@ export interface CheckoutAttributionMetadata {
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
export type PlatformSource = 'cloud' | 'desktop_cloud' | 'desktop_local'
|
||||
|
||||
export interface SubscriptionMetadata {
|
||||
current_tier?: string
|
||||
reason?: SubscriptionDialogReason
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
import { getCheckoutPlatformSource } from '../platformSource'
|
||||
|
||||
describe('getCheckoutPlatformSource', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
window.localStorage.clear()
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('classifies cloud checkout launched from desktop', () => {
|
||||
distribution.isCloud = true
|
||||
window.history.pushState({}, '', '/pricing?utm_source=comfy.desktop')
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
|
||||
})
|
||||
|
||||
it('classifies direct cloud checkout', () => {
|
||||
distribution.isCloud = true
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('cloud')
|
||||
})
|
||||
|
||||
it('classifies cloud checkout from persisted desktop attribution', () => {
|
||||
distribution.isCloud = true
|
||||
window.localStorage.setItem(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ utm_source: 'comfy.desktop' })
|
||||
)
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
|
||||
})
|
||||
|
||||
it('prefers current URL attribution over stored attribution', () => {
|
||||
distribution.isCloud = true
|
||||
window.localStorage.setItem(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ utm_source: 'comfy.desktop' })
|
||||
)
|
||||
window.history.pushState({}, '', '/pricing?utm_source=direct')
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('cloud')
|
||||
})
|
||||
|
||||
it('classifies desktop local checkout', () => {
|
||||
distribution.isDesktop = true
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('desktop_local')
|
||||
})
|
||||
|
||||
it('does not classify OSS browser checkout', () => {
|
||||
expect(getCheckoutPlatformSource()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,15 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { withTimeout } from 'es-toolkit/promise'
|
||||
|
||||
import type { CheckoutAttributionMetadata } from '../types'
|
||||
import type { AttributionQueryKey } from './checkoutAttributionStorage'
|
||||
import {
|
||||
asNonEmptyString,
|
||||
hasAttributionChanges,
|
||||
persistAttribution,
|
||||
readAttributionFromUrl,
|
||||
readStoredAttribution
|
||||
} from './checkoutAttributionStorage'
|
||||
import { getCheckoutPlatformSource } from './platformSource'
|
||||
|
||||
type GaIdentity = {
|
||||
client_id?: string
|
||||
@@ -16,99 +24,10 @@ const GA_IDENTITY_FIELDS = [
|
||||
] as const satisfies ReadonlyArray<GtagGetFieldName>
|
||||
type GaIdentityField = GtagGetFieldName
|
||||
|
||||
const ATTRIBUTION_QUERY_KEYS = [
|
||||
'im_ref',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'gclid',
|
||||
'gbraid',
|
||||
'wbraid'
|
||||
] as const
|
||||
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
|
||||
const GET_GA_IDENTITY_TIMEOUT_MS = 300
|
||||
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
|
||||
|
||||
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
|
||||
if (!stored) return {}
|
||||
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!isPlainObject(parsed)) return {}
|
||||
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = asNonEmptyString(parsed[key])
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistAttribution(
|
||||
payload: Partial<Record<AttributionQueryKey, string>>
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function readAttributionFromUrl(
|
||||
search: string
|
||||
): Partial<Record<AttributionQueryKey, string>> {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = params.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function hasAttributionChanges(
|
||||
existing: Partial<Record<AttributionQueryKey, string>>,
|
||||
incoming: Partial<Record<AttributionQueryKey, string>>
|
||||
): boolean {
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = incoming[key]
|
||||
if (value !== undefined && existing[key] !== value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function getGaIdentityField(
|
||||
measurementId: string,
|
||||
fieldName: GaIdentityField
|
||||
@@ -245,6 +164,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
platform_source: getCheckoutPlatformSource(),
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number,
|
||||
|
||||
95
src/platform/telemetry/utils/checkoutAttributionStorage.ts
Normal file
95
src/platform/telemetry/utils/checkoutAttributionStorage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
const ATTRIBUTION_QUERY_KEYS = [
|
||||
'im_ref',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'gclid',
|
||||
'gbraid',
|
||||
'wbraid'
|
||||
] as const
|
||||
|
||||
export type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function readStoredAttribution(): Partial<
|
||||
Record<AttributionQueryKey, string>
|
||||
> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
|
||||
if (!stored) return {}
|
||||
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!isPlainObject(parsed)) return {}
|
||||
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = asNonEmptyString(parsed[key])
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistAttribution(
|
||||
payload: Partial<Record<AttributionQueryKey, string>>
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function readAttributionFromUrl(
|
||||
search: string
|
||||
): Partial<Record<AttributionQueryKey, string>> {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = params.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function hasAttributionChanges(
|
||||
existing: Partial<Record<AttributionQueryKey, string>>,
|
||||
incoming: Partial<Record<AttributionQueryKey, string>>
|
||||
): boolean {
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = incoming[key]
|
||||
if (value !== undefined && existing[key] !== value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
20
src/platform/telemetry/utils/platformSource.ts
Normal file
20
src/platform/telemetry/utils/platformSource.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
|
||||
import type { PlatformSource } from '../types'
|
||||
import {
|
||||
readAttributionFromUrl,
|
||||
readStoredAttribution
|
||||
} from './checkoutAttributionStorage'
|
||||
|
||||
export function getCheckoutPlatformSource(): PlatformSource | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
if (isCloud) {
|
||||
const fromUrl = readAttributionFromUrl(window.location.search)
|
||||
const source = fromUrl.utm_source ?? readStoredAttribution().utm_source
|
||||
|
||||
return source === 'comfy.desktop' ? 'desktop_cloud' : 'cloud'
|
||||
}
|
||||
|
||||
return isDesktop ? 'desktop_local' : undefined
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
mockAxiosInstance,
|
||||
mockGetAuthHeaderOrThrow,
|
||||
mockGetFirebaseAuthHeaderOrThrow
|
||||
mockGetFirebaseAuthHeaderOrThrow,
|
||||
mockGetCheckoutPlatformSource
|
||||
} = vi.hoisted(() => ({
|
||||
mockAxiosInstance: {
|
||||
get: vi.fn(),
|
||||
@@ -13,7 +14,8 @@ const {
|
||||
interceptors: { response: { use: vi.fn() } }
|
||||
},
|
||||
mockGetAuthHeaderOrThrow: vi.fn(),
|
||||
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
|
||||
mockGetFirebaseAuthHeaderOrThrow: vi.fn(),
|
||||
mockGetCheckoutPlatformSource: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
@@ -47,6 +49,10 @@ vi.mock('@/stores/authStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/platformSource', () => ({
|
||||
getCheckoutPlatformSource: mockGetCheckoutPlatformSource
|
||||
}))
|
||||
|
||||
import { workspaceApi } from './workspaceApi'
|
||||
|
||||
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
|
||||
@@ -56,6 +62,7 @@ describe('workspaceApi', () => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
|
||||
mockGetFirebaseAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
|
||||
mockGetCheckoutPlatformSource.mockReturnValue('desktop_cloud')
|
||||
})
|
||||
|
||||
describe('authentication', () => {
|
||||
@@ -367,6 +374,7 @@ describe('workspaceApi', () => {
|
||||
plan_slug: 'pro-monthly',
|
||||
return_url: 'https://return.url',
|
||||
cancel_url: 'https://cancel.url',
|
||||
platform_source: 'desktop_cloud',
|
||||
team_credit_stop_id: undefined,
|
||||
billing_cycle: undefined
|
||||
},
|
||||
@@ -390,6 +398,7 @@ describe('workspaceApi', () => {
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
return_url: undefined,
|
||||
cancel_url: undefined,
|
||||
platform_source: 'desktop_cloud',
|
||||
team_credit_stop_id: 'team_700',
|
||||
billing_cycle: 'yearly'
|
||||
},
|
||||
@@ -457,7 +466,11 @@ describe('workspaceApi', () => {
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/billing/topup',
|
||||
{ amount_cents: 1000, idempotency_key: 'key-3' },
|
||||
{
|
||||
amount_cents: 1000,
|
||||
idempotency_key: 'key-3',
|
||||
platform_source: 'desktop_cloud'
|
||||
},
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
expect(result).toEqual(data)
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { PlatformSource } from '@/platform/telemetry/types'
|
||||
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
|
||||
import type {
|
||||
WorkspaceId,
|
||||
WorkspaceInviteId
|
||||
@@ -161,6 +163,7 @@ interface SubscribeRequest {
|
||||
idempotency_key?: string
|
||||
return_url?: string
|
||||
cancel_url?: string
|
||||
platform_source?: PlatformSource
|
||||
/** Required for the per-credit Team plan; selects the slider stop. */
|
||||
team_credit_stop_id?: string
|
||||
billing_cycle?: SubscribeBillingCycle
|
||||
@@ -283,6 +286,7 @@ export interface BillingBalanceResponse {
|
||||
interface CreateTopupRequest {
|
||||
amount_cents: number
|
||||
idempotency_key?: string
|
||||
platform_source?: PlatformSource
|
||||
}
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
@@ -660,6 +664,7 @@ export const workspaceApi = {
|
||||
plan_slug: planSlug,
|
||||
return_url: options.returnUrl,
|
||||
cancel_url: options.cancelUrl,
|
||||
platform_source: getCheckoutPlatformSource(),
|
||||
team_credit_stop_id: options.teamCreditStopId,
|
||||
billing_cycle: options.billingCycle
|
||||
} satisfies SubscribeRequest,
|
||||
@@ -746,7 +751,8 @@ export const workspaceApi = {
|
||||
api.apiURL('/billing/topup'),
|
||||
{
|
||||
amount_cents: amountCents,
|
||||
idempotency_key: idempotencyKey
|
||||
idempotency_key: idempotencyKey,
|
||||
platform_source: getCheckoutPlatformSource()
|
||||
} satisfies CreateTopupRequest,
|
||||
{ headers }
|
||||
)
|
||||
|
||||
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: overlayMock.overlayMessage,
|
||||
overlayTitle: overlayMock.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
},
|
||||
mobileNoWorkflow: 'No workflow',
|
||||
runCount: 'Run count',
|
||||
viewJob: 'View job'
|
||||
},
|
||||
menu: {
|
||||
run: 'Run'
|
||||
},
|
||||
menuLabels: {
|
||||
publish: 'Publish'
|
||||
},
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Queueing'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing input',
|
||||
details: '',
|
||||
extra_info: { input_name: 'prompt' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
setActivePinia(pinia)
|
||||
|
||||
useAppModeStore().selectedOutputs = [toNodeId(1)]
|
||||
if (hasError) {
|
||||
useExecutionErrorStore().lastNodeErrors = nodeErrors
|
||||
}
|
||||
|
||||
const toastTarget = document.createElement('div')
|
||||
|
||||
return render(LinearControls, {
|
||||
props: { mobile, toastTo: toastTarget },
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
AppModeWidgetList: true,
|
||||
Loader: true,
|
||||
PartnerNodesList: true,
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
ScrubableNumberInput: true,
|
||||
SubscribeToRunButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])('shows a workflow error warning in $label controls', ({ mobile }) => {
|
||||
renderControls({ hasError: true, mobile })
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(
|
||||
within(warning).getByText('Required input missing')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByRole('button', { name: 'Show errors in graph' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
const runButton = screen.getByRole('button', { name: 'Run' })
|
||||
expect(runButton).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without graph errors',
|
||||
({ mobile }) => {
|
||||
renderControls({ mobile })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show errors in graph' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without an active subscription',
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not show the warning when the error copy is empty', () => {
|
||||
overlayMock.overlayMessage = ''
|
||||
|
||||
renderControls({ hasError: true })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { computed, ref, toValue, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -14,11 +15,15 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
@@ -28,6 +33,8 @@ const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
|
||||
const { overlayMessage } = useErrorOverlayState()
|
||||
|
||||
const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -43,6 +50,13 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
const widgetListRef = useTemplateRef('widgetListRef')
|
||||
const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -134,9 +148,10 @@ function handleDragDrop() {
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
@@ -166,18 +181,24 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
data-testid="linear-run-button"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
@@ -198,9 +219,14 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing',
|
||||
viewErrorsInGraph: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: mocks.overlayMessage,
|
||||
overlayTitle: mocks.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useViewErrorsInGraph', () => ({
|
||||
useViewErrorsInGraph: () => ({
|
||||
viewErrorsInGraph: mocks.viewErrorsInGraph
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderWarning() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(LinearRunErrorWarning, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('LinearRunErrorWarning', () => {
|
||||
beforeEach(() => {
|
||||
mocks.viewErrorsInGraph.mockReset()
|
||||
})
|
||||
|
||||
it('shows the current error overlay title and message without a close action', () => {
|
||||
renderWarning()
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(warning).toHaveTextContent('Required input missing')
|
||||
expect(warning).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(screen.getByText('Required input missing')).toHaveAttribute(
|
||||
'title',
|
||||
'Required input missing'
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens graph errors when the action is clicked', async () => {
|
||||
const { user } = renderWarning()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show errors in graph' })
|
||||
)
|
||||
|
||||
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const { overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="linear-validation-warning"
|
||||
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div
|
||||
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
|
||||
data-testid="linear-validation-warning-description"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
|
||||
:title="overlayTitle"
|
||||
>
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-start gap-2"
|
||||
data-testid="linear-validation-warning-message"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="linear-view-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{ t('linearMode.error.goto') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,14 +17,6 @@ import {
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
JobsApiError,
|
||||
fetchHistoryPage
|
||||
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type {
|
||||
FetchHistoryPageResult,
|
||||
JobsPageRequest
|
||||
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -122,9 +114,8 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
return deletingAssetIds.has(assetId)
|
||||
}
|
||||
|
||||
// History pagination state
|
||||
// Pagination state
|
||||
const historyOffset = ref(0)
|
||||
const historyNextCursor = ref<string | null>(null)
|
||||
const hasMoreHistory = ref(true)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
@@ -132,12 +123,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
const loadedIds = shallowReactive(new Set<string>())
|
||||
|
||||
// Ids of every raw job walked so far, including ones that map to no
|
||||
// displayable asset (failed, cancelled, preview-less). Head-refresh gap
|
||||
// detection needs the full set: a burst of non-asset jobs at the top would
|
||||
// otherwise never overlap `loadedIds` and trigger a needless full reload.
|
||||
const loadedJobIds = new Set<string>()
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
: fetchInputFilesFromAPI
|
||||
@@ -162,141 +147,65 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert assets in sorted order (newest first), skipping already-loaded ids
|
||||
*/
|
||||
const mergeHistoryAssets = (newAssets: AssetItem[]) => {
|
||||
for (const asset of newAssets) {
|
||||
if (loadedIds.has(asset.id)) {
|
||||
continue
|
||||
}
|
||||
loadedIds.add(asset.id)
|
||||
|
||||
const assetTime = new Date(asset.created_at ?? 0).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimHistoryToLimit = () => {
|
||||
if (allHistoryItems.value.length <= MAX_HISTORY_ITEMS) return
|
||||
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
const fetchHistoryJobsPage = (page: JobsPageRequest) =>
|
||||
fetchHistoryPage(api.fetchApi.bind(api), BATCH_SIZE, page)
|
||||
|
||||
// Invalidates in-flight history fetches whenever the list is replaced, so
|
||||
// a stale continuation can't merge into (or move the cursor of) the new walk.
|
||||
let historyFetchEpoch = 0
|
||||
|
||||
// Tracks whether the walk is keyset-paginated, independent of the current
|
||||
// cursor value: once a cursor has been minted the walk stays in cursor mode
|
||||
// even after it exhausts (`historyNextCursor` back to null), so head-refresh
|
||||
// merges keep preserving scroll-loaded items instead of replacing them.
|
||||
let historyCursorMode = false
|
||||
|
||||
const isRejectedCursorError = (err: unknown): boolean =>
|
||||
err instanceof JobsApiError &&
|
||||
err.status === 400 &&
|
||||
err.errorCode === 'INVALID_CURSOR'
|
||||
|
||||
const fetchHistoryPageWithCursorRecovery = async (
|
||||
after: string | null,
|
||||
epoch: number
|
||||
): Promise<FetchHistoryPageResult> => {
|
||||
if (after == null)
|
||||
return fetchHistoryJobsPage({ offset: historyOffset.value })
|
||||
try {
|
||||
return await fetchHistoryJobsPage({ after })
|
||||
} catch (err) {
|
||||
// Drop only a rejected cursor (e.g. stale across a restart) to the
|
||||
// offset fallback; transient failures and superseded-walk
|
||||
// continuations must propagate so a valid/newer cursor isn't lost.
|
||||
if (!isRejectedCursorError(err) || epoch !== historyFetchEpoch) throw err
|
||||
console.warn('Stale history cursor rejected, resuming via offset:', err)
|
||||
historyNextCursor.value = null
|
||||
historyCursorMode = false
|
||||
historyOffset.value = 0
|
||||
allHistoryItems.value = []
|
||||
loadedIds.clear()
|
||||
loadedJobIds.clear()
|
||||
return fetchHistoryJobsPage({ offset: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one page of history assets and update reactive state.
|
||||
*
|
||||
* Pagination model: the server starts in offset mode and mints a
|
||||
* `next_cursor` on any page that has one; subsequent requests pass that
|
||||
* cursor (keyset mode). The walk upgrades automatically — offset paging is
|
||||
* only used until the first cursor is received.
|
||||
*
|
||||
* An empty page with no cursor is treated as terminal regardless of
|
||||
* `has_more`, because offset paging would refetch the same page forever.
|
||||
* A cursor that hasn't advanced (the server echoed back the value it was
|
||||
* given) is also treated as terminal to prevent an infinite dedup loop.
|
||||
*
|
||||
* @param loadMore - When `true`, appends the next page to the existing list
|
||||
* (infinite-scroll continuation). When `false` (default), resets all
|
||||
* pagination state and replaces the list with the first page.
|
||||
* @returns The current accumulated list of history asset items.
|
||||
* Fetch history assets with pagination support
|
||||
* @param loadMore - true for pagination (append), false for initial load (replace)
|
||||
*/
|
||||
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
|
||||
// Reset state for initial load
|
||||
if (!loadMore) {
|
||||
historyFetchEpoch += 1
|
||||
historyOffset.value = 0
|
||||
historyNextCursor.value = null
|
||||
historyCursorMode = false
|
||||
hasMoreHistory.value = true
|
||||
allHistoryItems.value = []
|
||||
loadedIds.clear()
|
||||
loadedJobIds.clear()
|
||||
}
|
||||
|
||||
const epoch = historyFetchEpoch
|
||||
const requestedAfter = loadMore ? historyNextCursor.value : null
|
||||
const page = await fetchHistoryPageWithCursorRecovery(requestedAfter, epoch)
|
||||
if (epoch !== historyFetchEpoch) return allHistoryItems.value
|
||||
// Fetch from server with offset
|
||||
const history = await api.getHistory(BATCH_SIZE, {
|
||||
offset: historyOffset.value
|
||||
})
|
||||
|
||||
page.jobs.forEach((job) => loadedJobIds.add(job.id))
|
||||
const newAssets = mapHistoryToAssets(page.jobs)
|
||||
// Convert JobListItems to AssetItems
|
||||
const newAssets = mapHistoryToAssets(history)
|
||||
|
||||
if (loadMore) {
|
||||
mergeHistoryAssets(newAssets)
|
||||
// Filter out duplicates and insert in sorted order
|
||||
for (const asset of newAssets) {
|
||||
if (loadedIds.has(asset.id)) {
|
||||
continue // Skip duplicates
|
||||
}
|
||||
loadedIds.add(asset.id)
|
||||
|
||||
// Find insertion index to maintain sorted order (newest first)
|
||||
const assetTime = new Date(asset.created_at ?? 0).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
// Asset is oldest, append to end
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
// Insert at the correct position
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Initial load: replace all
|
||||
allHistoryItems.value = newAssets
|
||||
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
||||
}
|
||||
|
||||
const cursorStuck =
|
||||
page.nextCursor != null && page.nextCursor === requestedAfter
|
||||
if (page.nextCursor != null) historyCursorMode = true
|
||||
// The server ignores `offset` once the walk is keyset-paginated, so only
|
||||
// advance it while still in offset mode; otherwise the offset used by the
|
||||
// recovery fallback would drift past valid rows.
|
||||
if (!historyCursorMode) historyOffset.value += page.jobs.length
|
||||
hasMoreHistory.value =
|
||||
page.hasMore &&
|
||||
!cursorStuck &&
|
||||
(page.jobs.length > 0 || page.nextCursor != null)
|
||||
// Drop the cursor once paging terminates so state never carries a live
|
||||
// cursor alongside `hasMoreHistory === false`.
|
||||
historyNextCursor.value = hasMoreHistory.value
|
||||
? (page.nextCursor ?? null)
|
||||
: null
|
||||
// Update pagination state
|
||||
historyOffset.value += BATCH_SIZE
|
||||
hasMoreHistory.value = history.length === BATCH_SIZE
|
||||
|
||||
trimHistoryToLimit()
|
||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
|
||||
// Clean up Set
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
return allHistoryItems.value
|
||||
}
|
||||
@@ -336,15 +245,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
isLoadingMore.value = true
|
||||
historyError.value = null
|
||||
|
||||
const epoch = historyFetchEpoch
|
||||
try {
|
||||
await fetchHistoryAssets(true)
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
historyAssets.value = allHistoryItems.value
|
||||
} catch (err) {
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
console.error('Error loading more history:', err)
|
||||
historyError.value = err
|
||||
// Keep existing data when error occurs (consistent with updateHistory)
|
||||
if (!historyAssets.value.length) {
|
||||
historyAssets.value = []
|
||||
}
|
||||
@@ -353,100 +260,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A head page with no further rows spans the whole timeline, so replacing
|
||||
* local state with it also prunes jobs deleted server-side (e.g. after the
|
||||
* queue history is cleared from another surface).
|
||||
*
|
||||
* Bumps `historyFetchEpoch`, which cancels any concurrent
|
||||
* `loadMoreHistory`/`fetchHistoryAssets` continuation.
|
||||
*/
|
||||
const replaceHistoryWithHeadPage = (page: FetchHistoryPageResult) => {
|
||||
historyFetchEpoch += 1
|
||||
const newAssets = mapHistoryToAssets(page.jobs)
|
||||
allHistoryItems.value = newAssets
|
||||
loadedIds.clear()
|
||||
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
||||
loadedJobIds.clear()
|
||||
page.jobs.forEach((job) => loadedJobIds.add(job.id))
|
||||
historyOffset.value = page.jobs.length
|
||||
historyNextCursor.value = page.nextCursor ?? null
|
||||
historyCursorMode = page.nextCursor != null
|
||||
hasMoreHistory.value = page.hasMore
|
||||
}
|
||||
|
||||
let headRefreshInFlight: Promise<void> | null = null
|
||||
let headRefreshTrailing: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Merge newly completed jobs into the top of the list without resetting
|
||||
* pagination state, so items loaded via infinite scroll survive the
|
||||
* refresh. Cursors only walk toward older items, so new completions are
|
||||
* picked up by re-fetching the head page and deduplicating. Bursts of
|
||||
* status events share the in-flight refresh, and a call arriving
|
||||
* mid-flight schedules exactly one trailing refresh — the shared response
|
||||
* was dispatched before that caller's event, so it could miss the very
|
||||
* completion the caller is reacting to.
|
||||
*/
|
||||
const refreshHistoryHead = (): Promise<void> => {
|
||||
if (!headRefreshInFlight) {
|
||||
headRefreshInFlight = doRefreshHistoryHead().finally(() => {
|
||||
headRefreshInFlight = null
|
||||
})
|
||||
return headRefreshInFlight
|
||||
}
|
||||
headRefreshTrailing ??= headRefreshInFlight.then(() => {
|
||||
headRefreshTrailing = null
|
||||
return refreshHistoryHead()
|
||||
})
|
||||
return headRefreshTrailing
|
||||
}
|
||||
|
||||
const doRefreshHistoryHead = async () => {
|
||||
historyError.value = null
|
||||
if (!allHistoryItems.value.length) {
|
||||
await updateHistory()
|
||||
return
|
||||
}
|
||||
|
||||
let epoch = historyFetchEpoch
|
||||
try {
|
||||
const page = await fetchHistoryJobsPage({ offset: 0 })
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
|
||||
const reachesLoadedItems = page.jobs.some((job) =>
|
||||
loadedJobIds.has(job.id)
|
||||
)
|
||||
if (page.hasMore && !reachesLoadedItems) {
|
||||
await updateHistory()
|
||||
return
|
||||
}
|
||||
|
||||
// Merging only preserves scroll-loaded items safely in cursor mode,
|
||||
// including once the cursor has exhausted (historyNextCursor is null but
|
||||
// the loaded terminal pages must survive). In offset fallback mode,
|
||||
// prepending new head rows without advancing historyOffset would drift
|
||||
// the next offset request (the server timeline shifted down by the new
|
||||
// completions), so rebuild from the head page — which resets
|
||||
// historyOffset to a position consistent with that page.
|
||||
if (page.hasMore && historyCursorMode) {
|
||||
page.jobs.forEach((job) => loadedJobIds.add(job.id))
|
||||
mergeHistoryAssets(mapHistoryToAssets(page.jobs))
|
||||
trimHistoryToLimit()
|
||||
} else {
|
||||
replaceHistoryWithHeadPage(page)
|
||||
// replaceHistoryWithHeadPage bumps the epoch; re-sync so the catch
|
||||
// guard below suppresses stale continuations, not genuine errors.
|
||||
epoch = historyFetchEpoch
|
||||
}
|
||||
historyAssets.value = allHistoryItems.value
|
||||
} catch (err) {
|
||||
if (epoch !== historyFetchEpoch) return
|
||||
console.error('Error refreshing history:', err)
|
||||
historyError.value = err
|
||||
}
|
||||
}
|
||||
|
||||
const flatOutputAssets = ref<AssetItem[]>([])
|
||||
const flatOutputLoading = ref(false)
|
||||
const flatOutputError = ref<unknown>(null)
|
||||
@@ -1071,7 +884,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updateInputs,
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
refreshHistoryHead,
|
||||
setAssetPreview,
|
||||
|
||||
// Flat output assets (cloud-only, tag-based)
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { PlatformSource } from '@/platform/telemetry/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
@@ -40,7 +41,9 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
type CreditPurchaseResponse =
|
||||
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
||||
type CreditPurchasePayload =
|
||||
operations['InitiateCreditPurchase']['requestBody']['content']['application/json']
|
||||
operations['InitiateCreditPurchase']['requestBody']['content']['application/json'] & {
|
||||
platform_source?: PlatformSource
|
||||
}
|
||||
type CreateCustomerResponse =
|
||||
operations['createCustomer']['responses']['201']['content']['application/json']
|
||||
|
||||
|
||||
@@ -100,10 +100,7 @@ vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ isBuilderMode: ref(false) })
|
||||
}))
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
updateHistory: vi.fn(),
|
||||
refreshHistoryHead: vi.fn()
|
||||
})
|
||||
useAssetsStore: () => ({ updateHistory: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ registerCommands: vi.fn() })
|
||||
|
||||
@@ -238,7 +238,7 @@ const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.refreshHistoryHead()
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ const onExecutionSuccess = async () => {
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.refreshHistoryHead()
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -165,7 +164,6 @@ function dragDrop(e: DragEvent) {
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
Reference in New Issue
Block a user