mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): follow-up fixes from cleanup review (#45420)
This commit is contained in:
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('')
|
||||
|
||||
+15
-23
@@ -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()
|
||||
|
||||
+2
-42
@@ -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',
|
||||
|
||||
+25
-23
@@ -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' }))
|
||||
|
||||
+87
-42
@@ -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' },
|
||||
|
||||
+4
-41
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user