From fbc33cae439912ef247309039e9805d86d2950b5 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 3 Nov 2026 08:51:15 +0000 Subject: [PATCH] test(web): follow-up fixes from cleanup review (#45420) --- .../snippet-list/__tests__/index.spec.tsx | 15 +- .../agent-strategy-selector.spec.tsx | 38 +- .../__tests__/code-generator-button.spec.tsx | 44 +- .../__tests__/method-item.spec.tsx | 48 +- ...use-single-run-form-params.hitl-v2.spec.ts | 129 ++++-- .../__tests__/prompt-generator-btn.spec.tsx | 45 +- .../panel/env-panel/__tests__/index.spec.tsx | 424 ++++++++++++++++-- .../run/__tests__/output-panel.spec.tsx | 30 +- web/test/console/generator-query-preset.ts | 48 ++ web/test/debounce.ts | 28 ++ 10 files changed, 618 insertions(+), 231 deletions(-) create mode 100644 web/test/console/generator-query-preset.ts create mode 100644 web/test/debounce.ts diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx index e06f076c7a0..537002fa820 100644 --- a/web/app/components/snippet-list/__tests__/index.spec.tsx +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -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 @@ -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[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('') diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx index d045c26349d..7cbd52a6471 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx @@ -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() diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/code-generator-button.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/code-generator-button.spec.tsx index c1f9bf9848e..04073a38820 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/code-generator-button.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/code-generator-button.spec.tsx @@ -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', diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx index bdec2ff4dbc..730d16a4e32 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx @@ -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('react-i18next') - const { createInstance } = await vi.importActual('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) => , - } -}) - const createEmailConfig = (overrides: Partial = {}): 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('react-i18next') + const { createInstance } = await vi.importActual('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) => , + ) + }) + + 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' })) diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.hitl-v2.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.hitl-v2.spec.ts index 21379acd57f..161f7f71578 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.hitl-v2.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.hitl-v2.spec.ts @@ -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> @@ -74,7 +72,10 @@ const getInputVars = vi.fn(() => [ }), ]) -type SubjectProps = { initialRunInputData?: Record } +type SubjectProps = { + initialRunInputData?: Record + singleRunOpen?: boolean +} const renderSubject = ( payload: NodeData, @@ -85,12 +86,15 @@ const renderSubject = ( props: SubjectProps = {}, ) => renderWorkflowCanvasHook( - ({ initialRunInputData }: SubjectProps) => { + ({ initialRunInputData, singleRunOpen }: SubjectProps) => { const node = useNodes().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, '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>({}) + 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' }, diff --git a/web/app/components/workflow/nodes/llm/components/__tests__/prompt-generator-btn.spec.tsx b/web/app/components/workflow/nodes/llm/components/__tests__/prompt-generator-btn.spec.tsx index 88589ea46c5..27ffaabf534 100644 --- a/web/app/components/workflow/nodes/llm/components/__tests__/prompt-generator-btn.spec.tsx +++ b/web/app/components/workflow/nodes/llm/components/__tests__/prompt-generator-btn.spec.tsx @@ -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', diff --git a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx index 5f8c2560717..8de4a5acf62 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx @@ -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 >(), 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>(), })) @@ -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>() + +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 => ({ id: 'env-1', @@ -165,6 +259,7 @@ const modelRule: ModelParameterRule = { } type RenderPanelOptions = { + collaboration?: boolean initialStoreState?: Partial 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( + + {ui} + {collaboration && } + , + { + 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')) diff --git a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx index 3f442c4813f..ca962f98132 100644 --- a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx +++ b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx @@ -23,11 +23,12 @@ const createFileOutput = (overrides: Partial = {}): FileOutput => ({ describe('OutputPanel', () => { it('shows no-output only after execution finishes without a result', async () => { - const { rerender } = render() + const { rerender } = render() expect(screen.queryByText('No Output')).not.toBeInTheDocument() + expect(await screen.findByText('Partial output', {}, { timeout: 5000 })).toBeVisible() rerender() - 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() - 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() @@ -99,14 +104,15 @@ describe('OutputPanel', () => { ).not.toBeInTheDocument() }) - it('renders structured outputs inside the code editor when height is available', () => { - render() + // 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() const editor = screen.getByTestId('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', () => { diff --git a/web/test/console/generator-query-preset.ts b/web/test/console/generator-query-preset.ts new file mode 100644 index 00000000000..ea656e8714d --- /dev/null +++ b/web/test/console/generator-query-preset.ts @@ -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 }, + ) +} diff --git a/web/test/debounce.ts b/web/test/debounce.ts new file mode 100644 index 00000000000..d2d66a3846c --- /dev/null +++ b/web/test/debounce.ts @@ -0,0 +1,28 @@ +import { act, configure, getConfig } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +type User = ReturnType + +export async function runDebouncedUserInteraction( + debounceMs: number, + interaction: (user: User) => Promise, +) { + 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() + } + } +}