test(web): follow-up fixes from cleanup review (#45420)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-03 08:51:15 +00:00
1 parent c3780668b8
commit fbc33cae43
10 files changed
+618 -231

No files matched your search

@@ -6,8 +6,10 @@ import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { SnippetDetailTop } from '@/app/components/snippets/components/snippet-detail-top'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { runDebouncedUserInteraction } from '@/test/debounce'
import { createDeferred } from '@/test/deferred'
import SnippetList from '..'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
type SnippetListResponse = Awaited<
ReturnType<typeof consoleClient.workspaces.current.customizedSnippets.get>
@@ -278,9 +280,6 @@ it.each([
})
it('searches and clears the filter through actual query state', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
listSnippets.mockImplementation(
async ({ query }: NonNullable<Parameters<typeof listSnippets>[0]> = {}) =>
page([snippet({ name: query?.keyword ? 'Summary snippet' : 'Sales Snippet' })]),
@@ -288,16 +287,14 @@ it('searches and clears the filter through actual query state', async () => {
const { onUrlUpdate } = renderList()
await salesCard()
const search = screen.getByRole('searchbox', { name: 'workflow.tabs.searchSnippets' })
await user.type(search, 'summary')
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
await runDebouncedUserInteraction(SNIPPET_LIST_SEARCH_DEBOUNCE_MS, async (user) => {
await user.type(search, 'summary')
})
expect(await screen.findByRole('link', { name: /Summary snippet/ })).toBeInTheDocument()
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('keywords')).toBe('summary')
expect(listSnippets).toHaveBeenLastCalledWith(searchParams({ keyword: 'summary' }))
await user.click(screen.getByRole('button', { name: 'common.operation.clear' }))
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
await runDebouncedUserInteraction(SNIPPET_LIST_SEARCH_DEBOUNCE_MS, async (user) => {
await user.click(screen.getByRole('button', { name: 'common.operation.clear' }))
})
expect(await salesCard()).toBeInTheDocument()
expect(search).toHaveValue('')
@@ -4,11 +4,14 @@ import userEvent from '@testing-library/user-event'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { createPlugin } from '@/app/components/workflow/block-selector/__tests__/factories'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { runDebouncedUserInteraction } from '@/test/debounce'
import { createDeferred } from '@/test/deferred'
import { createInstalledPluginFixture } from '@/test/fixtures/plugin'
import { createProvider, createStrategy } from '../../../agent/__tests__/strategy-fixture'
import { AgentStrategySelector } from '../agent-strategy-selector'
const MARKETPLACE_SEARCH_DEBOUNCE_MS = 500
const { request, marketplace, install, manifest } = vi.hoisted(() => ({
request: vi.fn(),
marketplace: vi.fn(),
@@ -46,17 +49,6 @@ afterEach(() => {
vi.unstubAllGlobals()
})
function searchUser() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime })
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
}
async function finishSearch() {
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
}
function namedProvider(name: string, label: string, author: string): AgentProviderResponse {
const provider = createProvider()
return {
@@ -217,12 +209,12 @@ it('keeps marketplace search and its category link alongside the installed catal
'/plugins/agent-strategy',
)
await screen.findByRole('button', { name: 'Agent provider' })
const search = searchUser()
await search.type(
screen.getByPlaceholderText('workflowAgent.nodes.agent.strategy.searchPlaceholder'),
'react',
)
await finishSearch()
await runDebouncedUserInteraction(MARKETPLACE_SEARCH_DEBOUNCE_MS, async (search) => {
await search.type(
screen.getByPlaceholderText('workflowAgent.nodes.agent.strategy.searchPlaceholder'),
'react',
)
})
expect(await screen.findByText('Marketplace strategy')).toBeInTheDocument()
expect(marketplace).toHaveBeenCalledWith(
'/plugins/search/advanced',
@@ -251,12 +243,12 @@ it('shows installed strategies once and keeps other marketplace results availabl
await user.click(screen.getByText('workflowAgent.nodes.agent.strategy.selectTip'))
expect(await screen.findByRole('button', { name: 'Agent provider' })).toBeInTheDocument()
const search = searchUser()
await search.type(
screen.getByPlaceholderText('workflowAgent.nodes.agent.strategy.searchPlaceholder'),
'agent',
)
await finishSearch()
await runDebouncedUserInteraction(MARKETPLACE_SEARCH_DEBOUNCE_MS, async (search) => {
await search.type(
screen.getByPlaceholderText('workflowAgent.nodes.agent.strategy.searchPlaceholder'),
'agent',
)
})
await screen.findByText('Available Marketplace')
expect(screen.queryByText('Installed Marketplace')).not.toBeInTheDocument()
expect(await screen.findByText('Available Marketplace')).toBeInTheDocument()
@@ -11,15 +11,8 @@ import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { seedGeneratorQueries } from '@/test/console/generator-query-preset'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { seedToolProviders } from '@/test/console/tool-providers'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import CodeGenerateBtn from '../code-generator-button'
@@ -32,40 +25,7 @@ vi.mock('@/service/debug', async (importOriginal) => ({
const renderGenerator = createNuqsTestRenderer((ui) => {
const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' })
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'gpt-4o-mini',
label: { en_US: 'GPT-4o mini' },
model_properties: { mode: 'chat' },
}),
],
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4o-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{ initialData: { data: [] } },
)
queryClient.setQueryData(
consoleQuery.instructionGenerate.template.post.queryOptions({
input: { body: { type: 'code' } },
}).queryKey,
{ data: 'Describe the Python change' },
)
seedGeneratorQueries(queryClient, { type: 'code', template: 'Describe the Python change' })
const start = createStartNode({
id: 'start',
@@ -4,6 +4,7 @@ import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import * as reactI18next from 'react-i18next'
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
import {
createEdge,
@@ -11,6 +12,7 @@ import {
createStartNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import translations from '@/i18n/locales/en-US/workflow-human-input.json'
import { renderWorkflowEditor } from '@/test/workflow-editor'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodItem from '../method-item'
@@ -26,25 +28,6 @@ vi.mock('@/service/console', async (importOriginal) => {
}
})
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const { createInstance } = await vi.importActual<typeof import('i18next')>('i18next')
const { createReactI18nextMock } = await import('@/test/i18n-mock')
const { default: translations } = await import('@/i18n/locales/en-US/workflow-human-input.json')
const i18n = createInstance()
await i18n.init({
lng: 'en-US',
fallbackLng: false,
keySeparator: false,
resources: { 'en-US': { workflowHumanInput: translations } },
})
return {
...actual,
...createReactI18nextMock(),
Trans: (props: ComponentProps<typeof actual.Trans>) => <actual.Trans {...props} i18n={i18n} />,
}
})
const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ({
recipients: {
whole_workspace: false,
@@ -151,6 +134,25 @@ const renderMethod = (initialMethod: DeliveryMethod, readonly = false) => {
const savedMethod = () => screen.getByRole('status', { name: 'Saved delivery method' })
describe('human-input/delivery-method/method-item', () => {
beforeEach(async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const { createInstance } = await vi.importActual<typeof import('i18next')>('i18next')
const i18n = createInstance()
await i18n.init({
lng: 'en-US',
fallbackLng: false,
keySeparator: false,
resources: { 'en-US': { workflowHumanInput: translations } },
})
vi.spyOn(reactI18next, 'Trans').mockImplementation(
(props: ComponentProps<typeof actual.Trans>) => <actual.Trans {...props} i18n={i18n} />,
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('toggles and deletes a webapp delivery method through controlled owner state', async () => {
const user = userEvent.setup()
renderMethod({ id: 'webapp-1', type: DeliveryMethodType.WebApp, enabled: true })
@@ -170,8 +172,6 @@ describe('human-input/delivery-method/method-item', () => {
it('uses the real test sender and saves subject and body edits from the real configuration form', async () => {
const user = userEvent.setup()
// The jump button exists only for non-debug email with specific recipients; a debug fixture
// cannot reach this production branch.
renderMethod({
id: 'email-1',
type: DeliveryMethodType.Email,
@@ -188,9 +188,11 @@ describe('human-input/delivery-method/method-item', () => {
await user.click(testButton)
sender = screen.getByRole('dialog', { name: /emailSender.title/ })
await user.click(within(sender).getByRole('button', { name: 'enable Debug Mode' }))
const configure = await screen.findByRole('dialog', { name: /emailConfigure.title/ })
const configureFromSender = await screen.findByRole('dialog', { name: /emailConfigure.title/ })
expect(screen.queryByRole('dialog', { name: /emailSender.title/ })).not.toBeInTheDocument()
await user.click(within(configure).getByRole('button', { name: 'common.operation.cancel' }))
await user.click(
within(configureFromSender).getByRole('button', { name: 'common.operation.cancel' }),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'workflow.common.configure' }))
@@ -4,10 +4,10 @@ import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-s
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createElement, StrictMode, useEffect, useRef, useState } from 'react'
import { useNodes } from 'reactflow'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { withSelectorKey } from '@/test/i18n-mock'
import { createNode } from '../../../../__tests__/fixtures'
import {
renderWorkflowCanvasHook,
@@ -15,12 +15,10 @@ import {
} from '../../../../__tests__/workflow-test-env'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockFetchHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockSubmitHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.mock('@/app/notifications', () => ({ toast: { error: mockToastError } }))
vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation() }))
type NodeData = HumanInputNodeType & Partial<Pick<HumanInputV2NodeType, 'version'>>
@@ -74,7 +72,10 @@ const getInputVars = vi.fn(() => [
}),
])
type SubjectProps = { initialRunInputData?: Record<string, string> }
type SubjectProps = {
initialRunInputData?: Record<string, string>
singleRunOpen?: boolean
}
const renderSubject = (
payload: NodeData,
@@ -85,12 +86,15 @@ const renderSubject = (
props: SubjectProps = {},
) =>
renderWorkflowCanvasHook(
({ initialRunInputData }: SubjectProps) => {
({ initialRunInputData, singleRunOpen }: SubjectProps) => {
const node = useNodes<NodeData>().find(({ id }) => id === 'node-1')!
const [runInputData, setRunInputData] = useState(initialRunInputData ?? {})
const form = useSingleRunFormParams({
id: node.id,
payload: node.data,
payload: {
...node.data,
_isSingleRun: singleRunOpen ?? node.data._isSingleRun,
},
runInputData,
getInputVars,
setRunInputData,
@@ -105,21 +109,8 @@ const renderSubject = (
},
)
const setSingleRunOpen = (
subject: Pick<ReturnType<typeof renderSubject>, 'setCanvas'>,
payload: NodeData,
open: boolean,
) => {
act(() =>
subject.setCanvas({
nodes: [createNode({ id: 'node-1', data: { ...payload, _isSingleRun: open } })],
}),
)
}
describe('human-input/hooks/use-single-run-form-params', () => {
beforeEach(() => {
mockUseTranslation.mockReturnValue({ t: withSelectorKey((key: string) => key) })
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(mockFormData)
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
})
@@ -169,7 +160,9 @@ describe('human-input/hooks/use-single-run-form-params', () => {
expect(mockFetchHumanInputNodeStepRunForm).not.toHaveBeenCalled()
await waitFor(() => expect(subject.result.current.form.showGeneratedForm).toBe(false))
await waitFor(() => expect(subject.result.current.form.isGeneratingForm).toBe(false))
expect(mockToastError).toHaveBeenCalledWith('nodes.humanInputV2.template.testSaveFailed')
expect(mockToastError).toHaveBeenCalledWith(
'workflow.nodes.humanInputV2.template.testSaveFailed',
)
})
it('keeps the input screen available after a failed preview and allows retry', async () => {
@@ -192,13 +185,15 @@ describe('human-input/hooks/use-single-run-form-params', () => {
})
const subject = renderSubject(createPayload())
await act(async () => subject.result.current.form.handleShowGeneratedForm({}))
expect(subject.result.current.form.formData?.inputs).toEqual([
{
type: InputVarType.paragraph,
output_variable_name: 'answer',
default: { type: 'constant', value: '', selector: [] },
},
])
await waitFor(() =>
expect(subject.result.current.form.formData?.inputs).toEqual([
{
type: InputVarType.paragraph,
output_variable_name: 'answer',
default: { type: 'constant', value: '', selector: [] },
},
]),
)
act(() => subject.result.current.form.handleHideGeneratedForm())
mockFetchHumanInputNodeStepRunForm.mockResolvedValueOnce({
...mockFormData,
@@ -260,6 +255,7 @@ describe('human-input/hooks/use-single-run-form-params', () => {
)
it('ignores an old form response after the single-run session closes and reopens', async () => {
const user = userEvent.setup()
const payload = createPayload({ _isSingleRun: true })
let resolveOld!: (value: HumanInputFormData) => void
mockFetchHumanInputNodeStepRunForm.mockImplementationOnce(
@@ -268,26 +264,75 @@ describe('human-input/hooks/use-single-run-form-params', () => {
resolveOld = resolve
}),
)
const subject = renderSubject(payload)
act(() => {
subject.result.current.form.handleShowGeneratedForm({ topic: 'old' })
const ControlledCaller = () => {
const [isSingleRun, setIsSingleRun] = useState(true)
const [runInputData, setRunInputData] = useState<Record<string, string>>({})
const form = useSingleRunFormParams({
id: 'node-1',
payload: { ...payload, _isSingleRun: isSingleRun },
runInputData,
getInputVars,
setRunInputData,
})
return createElement(
'div',
null,
createElement('button', { onClick: () => setIsSingleRun(false) }, 'Close'),
createElement('button', { onClick: () => setIsSingleRun(true) }, 'Open'),
createElement(
'button',
{ onClick: () => void form.handleShowGeneratedForm({ topic: 'old' }) },
'Preview old',
),
createElement(
'button',
{ onClick: () => void form.handleShowGeneratedForm({ topic: 'new' }) },
'Preview new',
),
createElement(
'button',
{
onClick: () =>
void form.handleSubmitHumanInputForm({
action: 'approve',
inputs: {},
form_inputs: {},
}),
},
'Submit',
),
createElement(
'output',
{ 'aria-label': 'Form content' },
form.formData?.form_content ?? '',
),
)
}
renderWorkflowFlowComponent(createElement(ControlledCaller), {
nodes: [createNode({ id: 'node-1', data: payload })],
hooksStoreProps: {
doSyncWorkflowDraft: vi.fn(async () => ({ hash: 'saved', updatedAt: 1 })),
},
resource: { kind: 'app', appId: 'app-1', mode: 'workflow' },
})
setSingleRunOpen(subject, payload, false)
setSingleRunOpen(subject, payload, true)
await act(async () => subject.result.current.form.handleShowGeneratedForm({ topic: 'new' }))
await user.click(screen.getByRole('button', { name: 'Preview old' }))
await waitFor(() => expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledTimes(1))
await user.click(screen.getByRole('button', { name: 'Close' }))
await user.click(screen.getByRole('button', { name: 'Open' }))
await user.click(screen.getByRole('button', { name: 'Preview new' }))
await waitFor(() =>
expect(screen.getByRole('status', { name: 'Form content' })).toHaveTextContent(
'Rendered content',
),
)
await act(async () => {
resolveOld({ ...mockFormData, form_content: 'Old response' })
})
await waitFor(() =>
expect(subject.result.current.form.formData?.form_content).toBe('Rendered content'),
)
await act(async () =>
subject.result.current.form.handleSubmitHumanInputForm({
action: 'approve',
inputs: {},
form_inputs: {},
}),
expect(screen.getByRole('status', { name: 'Form content' })).toHaveTextContent(
'Rendered content',
)
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(mockSubmitHumanInputNodeStepRunForm).toHaveBeenLastCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{ inputs: { topic: 'new' }, form_inputs: {}, action: 'approve' },
@@ -10,15 +10,8 @@ import {
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { seedGeneratorQueries } from '@/test/console/generator-query-preset'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { seedToolProviders } from '@/test/console/tool-providers'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import PromptGeneratorBtn from '../prompt-generator-btn'
@@ -31,40 +24,10 @@ vi.mock('@/service/debug', async (importOriginal) => ({
const renderGenerator = createNuqsTestRenderer((ui) => {
const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' })
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
seedGeneratorQueries(queryClient, {
type: 'prompt',
template: 'Describe the prompt improvement',
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'gpt-4o-mini',
label: { en_US: 'GPT-4o mini' },
model_properties: { mode: 'chat' },
}),
],
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4o-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{ initialData: { data: [] } },
)
queryClient.setQueryData(
consoleQuery.instructionGenerate.template.post.queryOptions({
input: { body: { type: 'prompt' } },
}).queryKey,
{ data: 'Describe the prompt improvement' },
)
const start = createStartNode({
id: 'start',
@@ -5,13 +5,20 @@ import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable, Node } from '@/app/components/workflow/types'
import type { Edge, EnvironmentVariable, Node } from '@/app/components/workflow/types'
import type { consoleClient } from '@/service/console'
import { zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Fragment, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { createSocket } from '@/app/components/workflow/collaboration/core/__tests__/test-socket'
import {
CollaborationManager,
collaborationManager,
} from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import { isLLMEnvironmentVariableValue } from '@/app/components/workflow/llm-environment-variable'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum } from '@/app/components/workflow/types'
@@ -71,7 +78,10 @@ const {
mockDoSyncWorkflowDraft,
mockUpdateEnvironmentVariables,
mockFetchWorkflowDraft,
mockConnectSocket,
mockDisconnectSocket,
mockGetSocket,
mockIsSocketConnected,
mockFetchModelParameterRulesForModel,
} = vi.hoisted(() => ({
mockDoSyncWorkflowDraft: vi.fn<
@@ -95,8 +105,11 @@ const {
}) => Promise<unknown>
>(),
mockFetchWorkflowDraft: vi.fn<() => Promise<{ environment_variables: EnvironmentVariable[] }>>(),
mockConnectSocket: vi.fn(),
mockDisconnectSocket: vi.fn(),
mockGetSocket:
vi.fn<(appId: string) => { emit: (event: string, payload: unknown) => void } | null>(),
mockIsSocketConnected: vi.fn<(appId: string) => boolean>(),
mockFetchModelParameterRulesForModel:
vi.fn<(provider: string, modelId: string) => Promise<ModelParameterRule[]>>(),
}))
@@ -131,11 +144,92 @@ vi.mock('@/service/workflow', () => ({
}) => mockUpdateEnvironmentVariables(payload),
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: (appId: string) => mockGetSocket(appId),
vi.mock(
'@/app/components/workflow/collaboration/core/websocket-manager',
async (importOriginal) => {
const actual =
await importOriginal<
typeof import('@/app/components/workflow/collaboration/core/websocket-manager')
>()
return {
...actual,
isDefaultSocketUrl: () => true,
webSocketClient: {
...actual.webSocketClient,
connect: (appId: string) => mockConnectSocket(appId),
disconnect: (appId: string) => mockDisconnectSocket(appId),
getSocket: (appId: string) => mockGetSocket(appId),
isConnected: (appId: string) => mockIsSocketConnected(appId),
},
}
},
}))
)
const collaborationConnections: Array<{
connectionId: string
manager: CollaborationManager
}> = []
const collaborationTransports = new Map<string, ReturnType<typeof createSocket>>()
function registerCollaborationTransport(appId: string) {
const transport = createSocket(`socket-${appId}`)
collaborationTransports.set(appId, transport)
return transport
}
async function connectCollaboration(role: 'leader' | 'follower') {
const transport = registerCollaborationTransport('app-1')
const connectionId = await collaborationManager.connect('app-1')
collaborationConnections.push({ connectionId, manager: collaborationManager })
transport.receive('status', { isLeader: role === 'leader' })
transport.emit.mockClear()
return transport
}
async function createRemoteCollaboration(nodes: Node[]) {
const appId = `remote-${collaborationConnections.length}`
const transport = registerCollaborationTransport(appId)
const manager = new CollaborationManager()
let remoteNodes = nodes
let remoteEdges: Edge[] = []
const reactFlowStore = {
getState: () => ({
getNodes: () => remoteNodes,
setNodes: (next: Node[]) => {
remoteNodes = next
},
getEdges: () => remoteEdges,
setEdges: (next: Edge[]) => {
remoteEdges = next
},
}),
}
const connectionId = await manager.connect(appId, reactFlowStore)
collaborationConnections.push({ connectionId, manager })
transport.receive('status', { isLeader: true })
transport.emit.mockClear()
return { manager, nodes: () => remoteNodes, ...transport }
}
function CollaborationConnector() {
const store = useStoreApi()
const collaborationStore = useMemo(
() => ({
getState: () => {
const state = store.getState()
return {
getNodes: state.getNodes,
setNodes: state.setNodes,
getEdges: () => state.edges,
setEdges: state.setEdges,
}
},
}),
[store],
)
useCollaboration('app-1', true, collaborationStore)
return null
}
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
@@ -165,6 +259,7 @@ const modelRule: ModelParameterRule = {
}
type RenderPanelOptions = {
collaboration?: boolean
initialStoreState?: Partial<Shape>
nodes?: Node[]
resource?: EditorResource
@@ -174,13 +269,14 @@ const renderPanelWithNuqs = createNuqsTestRenderer(
(
ui,
{
collaboration = false,
initialStoreState = {},
nodes = [],
resource = { kind: 'app', appId: 'app-1', mode: 'workflow' },
}: RenderPanelOptions = {},
) => {
const { queryClient } = createConsoleQueryWrapper({
systemFeatures: { enable_collaboration_mode: false },
systemFeatures: { enable_collaboration_mode: collaboration },
})
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
@@ -216,19 +312,25 @@ const renderPanelWithNuqs = createNuqsTestRenderer(
{ initialData: { data: [modelRule] } },
)
let flow: ReactFlowInstance | undefined
const result = renderWorkflowFlowComponent(ui, {
resource,
queryClient,
nodes,
initialStoreState,
hooksStoreProps: { doSyncWorkflowDraft: mockDoSyncWorkflowDraft },
reactFlowProps: {
nodeTypes: { custom: () => null },
onInit: (instance) => {
flow = instance
const result = renderWorkflowFlowComponent(
<Fragment>
{ui}
{collaboration && <CollaborationConnector />}
</Fragment>,
{
resource,
queryClient,
nodes,
initialStoreState,
hooksStoreProps: { doSyncWorkflowDraft: mockDoSyncWorkflowDraft },
reactFlowProps: {
nodeTypes: { custom: () => null },
onInit: (instance) => {
flow = instance
},
},
},
})
)
return {
...result,
@@ -327,18 +429,39 @@ describe('EnvPanel container', () => {
mockDoSyncWorkflowDraft.mockReset()
mockUpdateEnvironmentVariables.mockReset()
mockFetchWorkflowDraft.mockReset()
mockConnectSocket.mockReset()
mockDisconnectSocket.mockReset()
mockFetchModelParameterRulesForModel.mockReset()
mockGetSocket.mockReset()
mockIsSocketConnected.mockReset()
collaborationTransports.clear()
mockConnectSocket.mockImplementation((appId: string) => {
const transport = collaborationTransports.get(appId)
if (!transport) throw new Error(`Missing collaboration transport for ${appId}`)
return transport.socket
})
mockGetSocket.mockImplementation(
(appId: string) => collaborationTransports.get(appId)?.socket ?? null,
)
mockIsSocketConnected.mockImplementation(
(appId: string) => collaborationTransports.get(appId)?.socket.connected ?? false,
)
mockDisconnectSocket.mockImplementation((appId: string) => {
const transport = collaborationTransports.get(appId)
if (transport) transport.socket.connected = false
})
mockDoSyncWorkflowDraft.mockImplementation(async (_notRefresh, callback) => {
callback?.onSuccess?.()
})
mockUpdateEnvironmentVariables.mockResolvedValue({})
mockFetchWorkflowDraft.mockRejectedValue(new Error('draft refresh unavailable'))
mockFetchModelParameterRulesForModel.mockResolvedValue([])
mockGetSocket.mockReturnValue(null)
})
afterEach(() => {
for (const { connectionId, manager } of collaborationConnections.splice(0).reverse())
manager.disconnect(connectionId)
collaborationTransports.clear()
vi.restoreAllMocks()
})
@@ -468,7 +591,7 @@ describe('EnvPanel container', () => {
it('adds secret variables and stores only masked values after persistence', async () => {
const user = userEvent.setup()
vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true)
await connectCollaboration('leader')
mockFetchWorkflowDraft.mockImplementationOnce(async () => {
const saved = mockUpdateEnvironmentVariables.mock.calls[0]![0].environmentVariables[0]!
return {
@@ -697,15 +820,20 @@ describe('EnvPanel container', () => {
it('persists follower graph edits before asking the leader to sync', async () => {
const user = userEvent.setup()
const emit = vi.fn()
mockGetSocket.mockReturnValue({ emit })
vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true)
vi.spyOn(collaborationManager, 'getIsLeader').mockReturnValue(false)
const env = oldModelEnv()
const { store } = renderPanel({
nodes: [createLLMNode({ data: { model_selector: ['env', env.name] } })],
const llmNode = createLLMNode({ data: { model_selector: ['env', env.name] } })
const remote = await createRemoteCollaboration([llmNode])
const local = registerCollaborationTransport('app-1')
const { getNodes, store } = renderPanel({
collaboration: true,
nodes: [llmNode],
initialStoreState: { environmentVariables: [env], envSecrets: {} },
})
await waitFor(() => expect(mockConnectSocket).toHaveBeenCalledWith('app-1'))
local.receive('status', { isLeader: false })
local.receive('graph_update', remote.snapshot())
await waitFor(() => expect(collaborationManager.canPersistLocalGraph()).toBe(true))
local.emit.mockClear()
await changeExistingModel(user, env.name)
@@ -722,12 +850,82 @@ describe('EnvPanel container', () => {
deletedEnvironmentVariableIds: [],
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith('collaboration_event', {
expect(getNodes()[0]?.data.model).toMatchObject({
provider: 'new-provider',
name: 'new-model',
})
expect(local.emit).toHaveBeenCalledWith('collaboration_event', {
type: 'vars_and_features_update',
data: { syncWorkflowDraft: true },
})
})
it('reconciles the latest remote node parameters after model rules finish loading', async () => {
const user = userEvent.setup()
const env = oldModelEnv()
const staleNode = createLLMNode({
data: {
model: {
provider: 'old-provider',
name: 'old-model',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
model_selector: ['env', env.name],
},
})
const latestNode = createLLMNode({
data: {
...staleNode.data,
model: {
...staleNode.data.model,
completion_params: { temperature: 0.9, obsolete: true },
},
},
})
let resolveRules!: (rules: ModelParameterRule[]) => void
mockFetchModelParameterRulesForModel.mockReturnValueOnce(
new Promise((resolve) => {
resolveRules = resolve
}),
)
const remote = await createRemoteCollaboration([staleNode])
const local = registerCollaborationTransport('app-1')
const { getNodes } = renderPanel({
collaboration: true,
nodes: [staleNode],
initialStoreState: { environmentVariables: [env], envSecrets: {} },
})
await waitFor(() => expect(mockConnectSocket).toHaveBeenCalledWith('app-1'))
local.receive('status', { isLeader: false })
local.receive('graph_update', remote.snapshot())
await waitFor(() => expect(collaborationManager.canPersistLocalGraph()).toBe(true))
await openEditVariable(user, env.name)
await selectModel(user, 'old-model', 'new-model')
await saveForm(user)
await waitFor(() => expect(mockFetchModelParameterRulesForModel).toHaveBeenCalledTimes(1))
remote.manager.setNodes(remote.nodes(), [latestNode])
local.receive('graph_update', remote.snapshot())
await waitFor(() =>
expect(getNodes()[0]?.data.model.completion_params).toEqual({
temperature: 0.9,
obsolete: true,
}),
)
await act(async () => resolveRules([modelRule]))
await waitFor(() =>
expect(getNodes()[0]?.data.model).toMatchObject({
provider: 'new-provider',
name: 'new-model',
completion_params: { temperature: 0.9 },
}),
)
})
it.each([false, true])(
'reports a follower save failure only if the server did not commit it (committed: %s)',
async (committed) => {
@@ -735,8 +933,7 @@ describe('EnvPanel container', () => {
const notifyError = vi.spyOn(toast, 'error')
notifyError.mockClear()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true)
vi.spyOn(collaborationManager, 'getIsLeader').mockReturnValue(false)
await connectCollaboration('follower')
const env = oldModelEnv()
const savedEnv = oldModelEnv({
value: { provider: 'new-provider', name: 'new-model', mode: 'chat' },
@@ -891,8 +1088,7 @@ describe('EnvPanel container', () => {
it('keeps model reconciliation when a remote snapshot changes only alias metadata', async () => {
const user = userEvent.setup()
vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true)
vi.spyOn(collaborationManager, 'getIsLeader').mockReturnValue(true)
await connectCollaboration('leader')
const env = oldModelEnv()
let savedEnv: EnvironmentVariable | undefined
mockDoSyncWorkflowDraft.mockImplementationOnce(async (_notRefresh, callback, options) => {
@@ -998,8 +1194,7 @@ describe('EnvPanel container', () => {
const env = oldModelEnv()
let persistedEnv = env
let resolveFirstPersistence!: () => void
vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true)
vi.spyOn(collaborationManager, 'getIsLeader').mockReturnValue(false)
await connectCollaboration('follower')
mockFetchWorkflowDraft.mockImplementation(async () => ({
environment_variables: [persistedEnv],
}))
@@ -1017,7 +1212,7 @@ describe('EnvPanel container', () => {
persistedEnv = payload.environmentVariables[0]!
return {}
})
const { getNodes, store } = renderPanel({
const { store } = renderPanel({
nodes: [createLLMNode({ data: { model_selector: ['env', env.name] } })],
initialStoreState: { environmentVariables: [env], envSecrets: {} },
})
@@ -1052,13 +1247,164 @@ describe('EnvPanel container', () => {
{ description: '', model: 'new-model' },
{ description: 'latest description', model: 'newest-model' },
])
expect(getNodes()[0]?.data.model).toMatchObject({
provider: 'new-provider',
name: 'newest-model',
})
})
it('keeps an edited secret available through draft fallback and then masks it', async () => {
it('preserves a remote update to an untouched alias across queued local saves', async () => {
const user = userEvent.setup()
await connectCollaboration('leader')
const localEnv = createEnv({
id: 'env-local',
name: 'local_value',
value_type: 'string',
value: 'local',
description: '',
})
const remoteEnv = createEnv({
id: 'env-remote',
name: 'remote_value',
value_type: 'string',
value: 'before-remote-update',
description: '',
})
const remotelyUpdatedEnv = { ...remoteEnv, value: 'after-remote-update' }
let serverEnvList = [localEnv, remoteEnv]
let resolveFirstPersistence!: () => void
mockFetchWorkflowDraft.mockImplementation(async () => ({
environment_variables: serverEnvList,
}))
mockUpdateEnvironmentVariables
.mockImplementationOnce(
(payload) =>
new Promise((resolve) => {
resolveFirstPersistence = () => {
serverEnvList = [payload.environmentVariables[0]!, remotelyUpdatedEnv]
resolve({})
}
}),
)
.mockImplementationOnce(async (payload) => {
serverEnvList = [payload.environmentVariables[0]!, remotelyUpdatedEnv]
return {}
})
const { store } = renderPanel({
initialStoreState: { environmentVariables: [localEnv, remoteEnv], envSecrets: {} },
})
await openEditVariable(user, localEnv.name)
await setTextField('workflow.env.modal.description', 'first local save', user)
await saveForm(user)
await waitFor(() => expect(mockUpdateEnvironmentVariables).toHaveBeenCalledTimes(1))
await openEditVariable(user, localEnv.name)
await setTextField('workflow.env.modal.description', 'second local save', user)
await saveForm(user)
expect(mockUpdateEnvironmentVariables).toHaveBeenCalledTimes(1)
await act(async () => resolveFirstPersistence())
await waitFor(() => expect(mockUpdateEnvironmentVariables).toHaveBeenCalledTimes(2))
await waitFor(() =>
expect(store.getState().environmentVariables).toEqual([
expect.objectContaining({ id: localEnv.id, description: 'second local save' }),
remotelyUpdatedEnv,
]),
)
})
it('keeps a newer server value for the same alias after a delayed local response', async () => {
const user = userEvent.setup()
await connectCollaboration('leader')
const env = createEnv({ value_type: 'string', value: 'initial', description: '' })
const remoteEnv = { ...env, value: 'newer-server-value', description: 'remote edit' }
let serverEnv = env
let resolvePersistence!: () => void
mockFetchWorkflowDraft.mockImplementation(async () => ({ environment_variables: [serverEnv] }))
mockUpdateEnvironmentVariables.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePersistence = () => resolve({})
}),
)
const { store } = renderPanel({
initialStoreState: { environmentVariables: [env], envSecrets: {} },
})
await openEditVariable(user, env.name)
await setTextField('workflow.env.modal.value', 'local-value', user)
await saveForm(user)
await waitFor(() => expect(mockUpdateEnvironmentVariables).toHaveBeenCalledTimes(1))
serverEnv = remoteEnv
await act(async () => resolvePersistence())
await waitFor(() => expect(store.getState().environmentVariables).toEqual([remoteEnv]))
})
it('carries a draft-fallback alias save into the next queued alias update', async () => {
const user = userEvent.setup()
const firstEnv = createEnv({
id: 'env-a',
name: 'first_value',
value_type: 'string',
value: 'first',
description: '',
})
const secondEnv = createEnv({
id: 'env-b',
name: 'second_value',
value_type: 'string',
value: 'second',
description: '',
})
let finishDraftFallback!: () => void
mockUpdateEnvironmentVariables.mockRejectedValueOnce(new Error('app-only endpoint'))
mockDoSyncWorkflowDraft.mockImplementationOnce(
(_notRefresh, callback) =>
new Promise((resolve) => {
finishDraftFallback = () => {
callback?.onSuccess?.()
resolve()
}
}),
)
const { store } = renderPanel({
initialStoreState: { environmentVariables: [firstEnv, secondEnv], envSecrets: {} },
})
await openEditVariable(user, firstEnv.name)
await setTextField('workflow.env.modal.value', 'first-updated', user)
await saveForm(user)
await waitFor(() => expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1))
await openEditVariable(user, secondEnv.name)
await setTextField('workflow.env.modal.value', 'second-updated', user)
await saveForm(user)
expect(mockUpdateEnvironmentVariables).toHaveBeenCalledTimes(1)
await act(async () => finishDraftFallback())
await waitFor(() => expect(mockUpdateEnvironmentVariables).toHaveBeenCalledTimes(2))
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(false, expect.any(Object), {
environmentVariablePatch: {
environmentVariables: [
expect.objectContaining({ id: firstEnv.id, value: 'first-updated' }),
],
deletedEnvironmentVariableIds: [],
},
})
expect(mockUpdateEnvironmentVariables.mock.calls[1]?.[0]).toEqual({
appId: 'app-1',
environmentVariables: [
expect.objectContaining({ id: secondEnv.id, value: 'second-updated' }),
],
deletedEnvironmentVariableIds: [],
})
await waitFor(() =>
expect(store.getState().environmentVariables).toEqual([
expect.objectContaining({ id: firstEnv.id, value: 'first-updated' }),
expect.objectContaining({ id: secondEnv.id, value: 'second-updated' }),
]),
)
})
it('persists a new masked secret after an existing secret changes through draft fallback', async () => {
const user = userEvent.setup()
const env = createEnv()
mockUpdateEnvironmentVariables.mockRejectedValueOnce(new Error('app-only endpoint'))
@@ -23,11 +23,12 @@ const createFileOutput = (overrides: Partial<FileOutput> = {}): FileOutput => ({
describe('OutputPanel', () => {
it('shows no-output only after execution finishes without a result', async () => {
const { rerender } = render(<OutputPanel isRunning />)
const { rerender } = render(<OutputPanel isRunning outputs={{ answer: 'Partial output' }} />)
expect(screen.queryByText('No Output')).not.toBeInTheDocument()
expect(await screen.findByText('Partial output', {}, { timeout: 5000 })).toBeVisible()
rerender(<OutputPanel isRunning={false} />)
expect(await screen.findByText('No Output')).toBeVisible()
})
expect(await screen.findByText('No Output', {}, { timeout: 5000 })).toBeVisible()
}, 10_000)
it('shows and copies a failed execution message', async () => {
const user = userEvent.setup()
@@ -47,19 +48,23 @@ describe('OutputPanel', () => {
/>,
)
expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: 'Run summary' }, { timeout: 5000 }),
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Review output' })).toHaveAttribute(
'href',
'https://example.com/review',
)
})
}, 10_000)
it('renders array text outputs as joined semantic markdown content', async () => {
render(<OutputPanel outputs={{ answer: ['## First result', '## Second result'] }} />)
expect(await screen.findByRole('heading', { name: 'First result' })).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: 'First result' }, { timeout: 5000 }),
).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Second result' })).toBeInTheDocument()
})
}, 10_000)
it('renders a file list for a single file output', () => {
render(<OutputPanel outputs={{ attachment: createFileOutput() }} />)
@@ -99,14 +104,15 @@ describe('OutputPanel', () => {
).not.toBeInTheDocument()
})
it('renders structured outputs inside the code editor when height is available', () => {
render(<OutputPanel height={200} outputs={{ answer: 'hello', score: 1 }} />)
// TODO: OutputPanel pre-serializes the object before CodeEditor serializes it again.
// Remove `.fails` when the editor receives the original object and exposes semantic JSON.
it.fails('renders structured outputs inside the code editor when height is available', () => {
const outputs = { answer: 'hello', score: 1 }
render(<OutputPanel height={200} outputs={outputs} />)
const editor = screen.getByTestId<HTMLInputElement>('monaco-editor')
expect(editor).toHaveAttribute('readonly')
const parsedValue: unknown = JSON.parse(editor.value)
const semanticValue = typeof parsedValue === 'string' ? JSON.parse(parsedValue) : parsedValue
expect(semanticValue).toEqual({ answer: 'hello', score: 1 })
expect(JSON.parse(editor.value)).toEqual(outputs)
})
it('skips the code editor when structured outputs have no positive height', () => {
@@ -0,0 +1,48 @@
import type { QueryClient } from '@tanstack/react-query'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from './model-provider'
import { seedToolProviders } from './tool-providers'
export const seedGeneratorQueries = (
queryClient: QueryClient,
{ template, type }: { template: string; type: 'code' | 'prompt' },
) => {
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'gpt-4o-mini',
label: { en_US: 'GPT-4o mini' },
model_properties: { mode: 'chat' },
}),
],
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4o-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{ initialData: { data: [] } },
)
queryClient.setQueryData(
consoleQuery.instructionGenerate.template.post.queryOptions({ input: { body: { type } } })
.queryKey,
{ data: template },
)
}
+28
View File
@@ -0,0 +1,28 @@
import { act, configure, getConfig } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
type User = ReturnType<typeof userEvent.setup>
export async function runDebouncedUserInteraction(
debounceMs: number,
interaction: (user: User) => Promise<void>,
) {
vi.useFakeTimers()
const originalAsyncWrapper = getConfig().asyncWrapper
// RTL's asyncWrapper drains a 0ms timer through Jest globals, which hangs with Vitest fake timers.
configure({ asyncWrapper: (callback) => callback() })
try {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await act(async () => interaction(user))
await act(async () => vi.advanceTimersByTimeAsync(debounceMs + 1))
await act(async () => vi.advanceTimersByTimeAsync(0))
} finally {
try {
configure({ asyncWrapper: originalAsyncWrapper })
} finally {
vi.useRealTimers()
}
}
}