mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): exercise workflow initialization and completion owners (#44660)
This commit is contained in:
1 parent
f6bb28b6ad
commit
6e4a368da4
3 files changed
+340
-747
No files matched your search
+252
-654
@@ -1,704 +1,302 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelAndParameter } from '../../types'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { screen } from '@testing-library/react'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
import { zProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Log from '@/app/components/base/chat/chat/log'
|
||||
import { TransferMethod } from '@/app/components/base/chat/types'
|
||||
import { FeaturesProvider } from '@/app/components/base/features/context'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { createCatalogModelFixture, createModelCatalogFixture } from '@/test/console/model-provider'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createDatasetFixture } from '@/test/fixtures/dataset'
|
||||
import {
|
||||
createDebugConfigurationFixture,
|
||||
createModelConfigFixture,
|
||||
} from '@/test/fixtures/debug-configuration'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../../types'
|
||||
import { DebugWithMultipleModelContextProvider } from '../context-provider'
|
||||
import TextGenerationItem from '../text-generation-item'
|
||||
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const modelListResponse = vi.fn()
|
||||
const mockUseFeatures = vi.fn()
|
||||
const mockUseTextGeneration = vi.fn()
|
||||
const { mockToastError } = vi.hoisted(() => ({
|
||||
mockToastError: vi.fn(),
|
||||
const requests = vi.hoisted(() => ({
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- The existing completion owner uses SSE; retain its typed streaming transport boundary.
|
||||
stream: vi.fn<typeof import('@/service/base').ssePost>(),
|
||||
log: vi.fn(),
|
||||
notifyError: vi.fn(),
|
||||
}))
|
||||
|
||||
let capturedTextGenerationProps: {
|
||||
onOpenLog: (item: IChatItem) => void
|
||||
content: string
|
||||
isLoading: boolean
|
||||
isResponding: boolean
|
||||
messageId: string | null
|
||||
className?: string
|
||||
} | null = null
|
||||
|
||||
let emitDebugEvent: ((v: { type: string; payload?: Record<string, unknown> }) => void) | null = null
|
||||
|
||||
vi.mock('@/context/debug-configuration', () => ({
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
vi.mock('@/service/base', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/base')>()),
|
||||
ssePost: requests.stream,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
|
||||
vi.mock('@/service/debug', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/debug')>()),
|
||||
fetchTextGenerationMessage: requests.log,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/text-generation/hooks', () => ({
|
||||
useTextGeneration: () => mockUseTextGeneration(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
toast: { error: requests.notifyError },
|
||||
}))
|
||||
vi.mock('@/next/navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/next/navigation')>()),
|
||||
useParams: () => ({ appId: 'app-123' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/text-generate/item', () => ({
|
||||
default: (props: typeof capturedTextGenerationProps) => {
|
||||
capturedTextGenerationProps = props
|
||||
return (
|
||||
<div data-testid="text-generation">
|
||||
{props && (
|
||||
<Log
|
||||
logItem={{ id: props.messageId!, isAnswer: true, content: props.content }}
|
||||
onOpenLog={props.onOpenLog}
|
||||
/>
|
||||
)}
|
||||
<span data-testid="content">{props?.content}</span>
|
||||
<span data-testid="is-loading">{props?.isLoading ? 'yes' : 'no'}</span>
|
||||
<span data-testid="is-responding">{props?.isResponding ? 'yes' : 'no'}</span>
|
||||
<span data-testid="message-id">{props?.messageId || 'null'}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
function EventSource({ children }: { children: ReactNode }) {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
emitDebugEvent = (event) => eventEmitter!.emit(event)
|
||||
return children
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryOptions({
|
||||
input: { params: { model_type: ModelTypeEnum.textGeneration } },
|
||||
}).queryKey,
|
||||
modelListResponse(),
|
||||
)
|
||||
return renderWithConsoleQuery(
|
||||
<EventEmitterContextProvider>
|
||||
<EventSource>{ui}</EventSource>
|
||||
</EventEmitterContextProvider>,
|
||||
{ queryClient },
|
||||
)
|
||||
}
|
||||
|
||||
const createModelAndParameter = (
|
||||
overrides: Partial<ModelAndParameter> = {},
|
||||
): ModelAndParameter => ({
|
||||
const model: ModelAndParameter = {
|
||||
id: 'model-1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
parameters: { temperature: 0.7 },
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
const localFile: VisionFile = {
|
||||
id: 'f1',
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'blob:123',
|
||||
upload_file_id: 'upload-1',
|
||||
}
|
||||
const remoteFile: VisionFile = {
|
||||
id: 'f2',
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/file.png',
|
||||
upload_file_id: '',
|
||||
}
|
||||
const DebugProvider = DebugConfigurationContext.Provider
|
||||
|
||||
const createDefaultMocks = () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'Hello {{name}}',
|
||||
prompt_variables: [{ key: 'name', name: 'Name', type: 'string', is_context_var: false }],
|
||||
},
|
||||
},
|
||||
function renderItem({
|
||||
configuration = createDebugConfigurationFixture({
|
||||
appId: 'app-123',
|
||||
mode: 'completion',
|
||||
inputs: { name: 'World' },
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: { enabled: true },
|
||||
introduction: 'Welcome!',
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
citationConfig: { enabled: true },
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: {},
|
||||
completionPromptConfig: {},
|
||||
dataSets: [{ id: 'ds-1' }],
|
||||
datasetConfigs: { retrieval_model: 'single' },
|
||||
})
|
||||
|
||||
modelListResponse.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
provider: 'openai',
|
||||
models: [
|
||||
{
|
||||
model: 'gpt-3.5-turbo',
|
||||
model_properties: { mode: 'chat' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
features: {
|
||||
moreLikeThis: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
file: { enabled: true },
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
})
|
||||
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'Generated text',
|
||||
handleSend: vi.fn(),
|
||||
isResponding: false,
|
||||
messageId: 'msg-123',
|
||||
})
|
||||
}),
|
||||
fileEnabled = true,
|
||||
onOpenLog = (_item: IChatItem) => {},
|
||||
}: {
|
||||
configuration?: ReturnType<typeof createDebugConfigurationFixture>
|
||||
fileEnabled?: boolean
|
||||
onOpenLog?: (item: IChatItem) => void
|
||||
} = {}) {
|
||||
let emit!: NonNullable<ReturnType<typeof useEventEmitterContextContext>['eventEmitter']>['emit']
|
||||
function EventSource({ children }: { children: ReactNode }) {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
emit = (event) => eventEmitter!.emit(event)
|
||||
return children
|
||||
}
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({
|
||||
input: { params: { model_type: ModelTypeEnum.textGeneration } },
|
||||
}),
|
||||
{
|
||||
data: [
|
||||
zProviderWithModelsResponse.parse(
|
||||
createModelCatalogFixture({
|
||||
models: [createCatalogModelFixture({ model_properties: { mode: 'chat' } })],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
renderWithConsoleQuery(
|
||||
<DebugProvider value={configuration}>
|
||||
<FeaturesProvider
|
||||
features={{
|
||||
file: { enabled: fileEnabled },
|
||||
moreLikeThis: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
}}
|
||||
>
|
||||
<EventEmitterContextProvider>
|
||||
<EventSource>
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[model]}
|
||||
onMultipleModelConfigsChange={() => {}}
|
||||
onDebugWithMultipleModelChange={() => {}}
|
||||
onOpenLog={onOpenLog}
|
||||
>
|
||||
<TextGenerationItem modelAndParameter={model} />
|
||||
</DebugWithMultipleModelContextProvider>
|
||||
</EventSource>
|
||||
</EventEmitterContextProvider>
|
||||
</FeaturesProvider>
|
||||
</DebugProvider>,
|
||||
{ queryClient },
|
||||
)
|
||||
return {
|
||||
send: (files: VisionFile[] = [], type = APP_CHAT_WITH_MULTIPLE_MODEL) => {
|
||||
act(() => emit({ type, payload: { message: 'Generate', files } }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
|
||||
const defaultProps = {
|
||||
modelAndParameter: createModelAndParameter(),
|
||||
...props,
|
||||
}
|
||||
return render(<TextGenerationItem {...defaultProps} />)
|
||||
function streamCallbacks() {
|
||||
expect(requests.stream).toHaveBeenCalledTimes(1)
|
||||
return requests.stream.mock.calls[0]![2]!
|
||||
}
|
||||
|
||||
function requestBody() {
|
||||
expect(requests.stream).toHaveBeenCalledTimes(1)
|
||||
expect(requests.stream.mock.calls[0]![0]).toBe('apps/app-123/completion-messages')
|
||||
return requests.stream.mock.calls[0]![1].body
|
||||
}
|
||||
|
||||
describe('TextGenerationItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
capturedTextGenerationProps = null
|
||||
emitDebugEvent = null
|
||||
createDefaultMocks()
|
||||
})
|
||||
|
||||
it('opens this model result through the owning debug session', async () => {
|
||||
it('shows loading, streams the result and opens its log through the debug session', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenLog = vi.fn()
|
||||
render(
|
||||
<DebugWithMultipleModelContextProvider
|
||||
multipleModelConfigs={[]}
|
||||
onMultipleModelConfigsChange={vi.fn()}
|
||||
onDebugWithMultipleModelChange={vi.fn()}
|
||||
onOpenLog={onOpenLog}
|
||||
>
|
||||
<TextGenerationItem modelAndParameter={createModelAndParameter()} />
|
||||
</DebugWithMultipleModelContextProvider>,
|
||||
requests.log.mockResolvedValue({ id: 'msg-123', answer: 'Generated text', message: [] })
|
||||
const { send } = renderItem({ onOpenLog })
|
||||
|
||||
send()
|
||||
expect(screen.getByLabelText('common.loading')).toBeInTheDocument()
|
||||
const stream = streamCallbacks()
|
||||
act(() =>
|
||||
stream.onData?.('Generated text', true, {
|
||||
messageId: 'msg-123',
|
||||
conversationId: '',
|
||||
taskId: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Generated text')).toBeVisible()
|
||||
expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument()
|
||||
act(() => stream.onCompleted?.())
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.log' }))
|
||||
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ id: 'msg-123', isAnswer: true }),
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onOpenLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'msg-123',
|
||||
content: 'Generated text',
|
||||
isAnswer: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(requests.log).toHaveBeenCalledWith({ appId: 'app-123', messageId: 'msg-123' })
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should show loading when no completion and responding', () => {
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend: vi.fn(),
|
||||
isResponding: true,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('is-loading')).toHaveTextContent('yes')
|
||||
})
|
||||
|
||||
it('should not show loading when completion exists', () => {
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: 'Some text',
|
||||
handleSend: vi.fn(),
|
||||
isResponding: true,
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('is-loading')).toHaveTextContent('no')
|
||||
})
|
||||
})
|
||||
|
||||
describe('config composition', () => {
|
||||
it('should use prompt_template in non-advanced mode', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'My Template',
|
||||
prompt_variables: [],
|
||||
},
|
||||
},
|
||||
appId: 'app-123',
|
||||
inputs: {},
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: {},
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: {},
|
||||
citationConfig: {},
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: {},
|
||||
completionPromptConfig: {},
|
||||
dataSets: [],
|
||||
datasetConfigs: {},
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Generate', files: [] },
|
||||
})
|
||||
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
|
||||
'apps/app-123/completion-messages',
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({ pre_prompt: 'My Template' }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use empty pre_prompt in advanced mode', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: true,
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'Should not be used',
|
||||
prompt_variables: [],
|
||||
},
|
||||
},
|
||||
appId: 'app-123',
|
||||
inputs: {},
|
||||
promptMode: 'advanced',
|
||||
speechToTextConfig: {},
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: {},
|
||||
citationConfig: {},
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: { custom: true },
|
||||
completionPromptConfig: { custom: true },
|
||||
dataSets: [],
|
||||
datasetConfigs: {},
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Generate', files: [] },
|
||||
})
|
||||
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
|
||||
'apps/app-123/completion-messages',
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
pre_prompt: '',
|
||||
prompt_type: 'advanced',
|
||||
chat_prompt_config: { custom: true },
|
||||
completion_prompt_config: { custom: true },
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should find context variable from prompt_variables', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [
|
||||
{ key: 'context', name: 'Context', type: 'string', is_context_var: true },
|
||||
{ key: 'query', name: 'Query', type: 'string', is_context_var: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
appId: 'app-123',
|
||||
inputs: {},
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: {},
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: {},
|
||||
citationConfig: {},
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: {},
|
||||
completionPromptConfig: {},
|
||||
dataSets: [],
|
||||
datasetConfigs: {},
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Generate', files: [] },
|
||||
})
|
||||
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
|
||||
'apps/app-123/completion-messages',
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({ dataset_query_variable: 'context' }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dataset configuration', () => {
|
||||
it('should transform dataSets to postDatasets format', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: {
|
||||
configs: { prompt_template: '', prompt_variables: [] },
|
||||
},
|
||||
appId: 'app-123',
|
||||
inputs: {},
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: {},
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: {},
|
||||
citationConfig: {},
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: {},
|
||||
completionPromptConfig: {},
|
||||
dataSets: [{ id: 'ds-1' }, { id: 'ds-2' }],
|
||||
datasetConfigs: { retrieval_model: 'multiple' },
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Generate', files: [] },
|
||||
})
|
||||
expect(mockUseTextGeneration().handleSend).toHaveBeenCalledWith(
|
||||
'apps/app-123/completion-messages',
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
dataset_configs: {
|
||||
retrieval_model: 'multiple',
|
||||
datasets: {
|
||||
datasets: [
|
||||
{ dataset: { enabled: true, id: 'ds-1' } },
|
||||
{ dataset: { enabled: true, id: 'ds-2' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event subscription', () => {
|
||||
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Generate text', files: [] },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
'apps/app-123/completion-messages',
|
||||
expect.objectContaining({
|
||||
inputs: { name: 'World' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onNotifyError: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
|
||||
const callbacks = handleSend.mock.calls[0]![2] as {
|
||||
onNotifyError: (message: string) => void
|
||||
}
|
||||
callbacks.onNotifyError('Base model not found')
|
||||
expect(mockToastError).toHaveBeenCalledWith('Base model not found')
|
||||
})
|
||||
|
||||
it('should ignore other event types', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: 'OTHER_EVENT',
|
||||
payload: {},
|
||||
})
|
||||
|
||||
expect(handleSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('doSend function', () => {
|
||||
it('should include model configuration', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3',
|
||||
parameters: { max_tokens: 2000 },
|
||||
})
|
||||
|
||||
renderComponent({ modelAndParameter })
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Test', files: [] },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
provider: 'anthropic',
|
||||
name: 'claude-3',
|
||||
completion_params: { max_tokens: 2000 },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ onNotifyError: expect.any(Function) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should include files with local_file transfer method handled', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const files = [
|
||||
{ id: 'f1', transfer_method: TransferMethod.local_file, url: 'blob:123' },
|
||||
{ id: 'f2', transfer_method: TransferMethod.remote_url, url: 'https://example.com/file' },
|
||||
]
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Test', files },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
id: 'f1',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'f2',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/file',
|
||||
}),
|
||||
it('sends the configured prompt, input schema, model and datasets to the completion service', () => {
|
||||
const configuration = createDebugConfigurationFixture({
|
||||
appId: 'app-123',
|
||||
mode: 'completion',
|
||||
inputs: { name: 'World', age: '42' },
|
||||
modelConfig: createModelConfigFixture({
|
||||
configs: {
|
||||
prompt_template: 'Hello {{name}}',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string', required: true },
|
||||
{ key: 'age', name: 'Age', type: 'number', required: true },
|
||||
{ key: 'context', name: 'Context', type: 'string', is_context_var: true },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({ onNotifyError: expect.any(Function) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not include files when file upload is disabled', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
mockUseFeatures.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
features: {
|
||||
moreLikeThis: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
file: { enabled: false },
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
)
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Test', files: [{ id: 'f1' }] },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
files: expect.anything(),
|
||||
}),
|
||||
expect.objectContaining({ onNotifyError: expect.any(Function) }),
|
||||
)
|
||||
}),
|
||||
dataSets: [createDatasetFixture({ id: 'ds-1' }), createDatasetFixture({ id: 'ds-2' })],
|
||||
})
|
||||
const { send } = renderItem({ configuration })
|
||||
send([localFile, remoteFile])
|
||||
|
||||
it('should not include files when files array is empty', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Test', files: [] },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
files: expect.anything(),
|
||||
}),
|
||||
expect.objectContaining({ onNotifyError: expect.any(Function) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('model resolution', () => {
|
||||
it('should find current provider and model', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
|
||||
modelListResponse.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
provider: 'openai',
|
||||
models: [
|
||||
{ model: 'gpt-3.5-turbo', model_properties: { mode: 'chat' } },
|
||||
{ model: 'gpt-4', model_properties: { mode: 'chat' } },
|
||||
expect(requestBody()).toMatchObject({
|
||||
response_mode: 'streaming',
|
||||
inputs: { name: 'World', age: '42' },
|
||||
model_config: {
|
||||
pre_prompt: 'Hello {{name}}',
|
||||
prompt_type: 'simple',
|
||||
dataset_query_variable: 'context',
|
||||
model: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-4',
|
||||
mode: 'chat',
|
||||
completion_params: { temperature: 0.7 },
|
||||
},
|
||||
file_upload: { enabled: true },
|
||||
dataset_configs: {
|
||||
datasets: {
|
||||
datasets: [
|
||||
{ dataset: { enabled: true, id: 'ds-1' } },
|
||||
{ dataset: { enabled: true, id: 'ds-2' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const modelAndParameter = createModelAndParameter({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
|
||||
renderComponent({ modelAndParameter })
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Test', files: [] },
|
||||
})
|
||||
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
mode: 'chat',
|
||||
},
|
||||
user_input_form: expect.arrayContaining([
|
||||
{
|
||||
'text-input': expect.objectContaining({
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ onNotifyError: expect.any(Function) }),
|
||||
)
|
||||
},
|
||||
{ number: expect.objectContaining({ variable: 'age', label: 'Age', required: true }) },
|
||||
]),
|
||||
},
|
||||
files: [
|
||||
{ id: 'f1', transfer_method: TransferMethod.local_file, url: '' },
|
||||
{
|
||||
id: 'f2',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/file.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptVariablesToUserInputsForm', () => {
|
||||
it('sends the generated input schema with the completion request', () => {
|
||||
const handleSend = vi.fn()
|
||||
mockUseTextGeneration.mockReturnValue({
|
||||
completion: '',
|
||||
handleSend,
|
||||
isResponding: false,
|
||||
messageId: null,
|
||||
})
|
||||
const promptVariables = [
|
||||
{ key: 'name', name: 'Name', type: 'string' },
|
||||
{ key: 'age', name: 'Age', type: 'number' },
|
||||
]
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: {
|
||||
configs: { prompt_template: '', prompt_variables: promptVariables },
|
||||
},
|
||||
appId: 'app-123',
|
||||
inputs: {},
|
||||
promptMode: 'simple',
|
||||
speechToTextConfig: {},
|
||||
introduction: '',
|
||||
suggestedQuestionsAfterAnswerConfig: {},
|
||||
citationConfig: {},
|
||||
externalDataToolsConfig: [],
|
||||
chatPromptConfig: {},
|
||||
completionPromptConfig: {},
|
||||
dataSets: [],
|
||||
datasetConfigs: {},
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
emitDebugEvent?.({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: { message: 'Generate text', files: [] },
|
||||
})
|
||||
expect(handleSend).toHaveBeenCalledWith(
|
||||
'apps/app-123/completion-messages',
|
||||
expect.objectContaining({
|
||||
model_config: expect.objectContaining({
|
||||
user_input_form: [
|
||||
{
|
||||
'text-input': expect.objectContaining({
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
number: expect.objectContaining({ variable: 'age', label: 'Age', required: true }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
it('sends advanced prompts instead of the simple template', () => {
|
||||
const configuration = createDebugConfigurationFixture({
|
||||
appId: 'app-123',
|
||||
mode: 'completion',
|
||||
isAdvancedMode: true,
|
||||
promptMode: PromptMode.advanced,
|
||||
modelConfig: createModelConfigFixture({
|
||||
configs: { prompt_template: 'Unused template', prompt_variables: [] },
|
||||
}),
|
||||
chatPromptConfig: { prompt: [{ role: 'system', text: 'Advanced chat prompt' }] },
|
||||
completionPromptConfig: {
|
||||
...createDebugConfigurationFixture().completionPromptConfig,
|
||||
prompt: { role: 'system', text: 'Advanced completion prompt' },
|
||||
},
|
||||
})
|
||||
const { send } = renderItem({ configuration })
|
||||
send()
|
||||
expect(requestBody()).toMatchObject({
|
||||
model_config: {
|
||||
pre_prompt: '',
|
||||
prompt_type: 'advanced',
|
||||
chat_prompt_config: configuration.chatPromptConfig,
|
||||
completion_prompt_config: configuration.completionPromptConfig,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'uploads are disabled',
|
||||
fileEnabled: false,
|
||||
files: [localFile],
|
||||
},
|
||||
{ name: 'no files are selected', fileEnabled: true, files: [] },
|
||||
])('omits attachments when $name', ({ fileEnabled, files }) => {
|
||||
const { send } = renderItem({ fileEnabled })
|
||||
send(files)
|
||||
expect(requestBody()).not.toHaveProperty('files')
|
||||
})
|
||||
|
||||
it('ignores unrelated events', () => {
|
||||
const { send } = renderItem()
|
||||
send([], 'OTHER_EVENT')
|
||||
expect(requests.stream).not.toHaveBeenCalled()
|
||||
expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports service notification errors', () => {
|
||||
const { send } = renderItem()
|
||||
send()
|
||||
act(() => streamCallbacks().onNotifyError?.('Base model not found'))
|
||||
expect(requests.notifyError).toHaveBeenCalledWith('Base model not found')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { WorkflowInitializationProbe } from '@/app/components/workflow/__tests__/workflow-initialization-probe'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -12,6 +14,7 @@ import { seedAppDetail } from '@/test/console/query-data'
|
||||
import { render as renderWithConsoleState } from '@/test/console/render'
|
||||
import { createAppDetailFixture } from '@/test/fixtures/app'
|
||||
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
|
||||
import { createWorkflowResponseFixture } from '@/test/fixtures/workflow-response'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import WorkflowApp from '../index'
|
||||
|
||||
@@ -20,18 +23,7 @@ let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
let appDetail: AppDetailWithSite
|
||||
|
||||
let workflowInitState: {
|
||||
data: {
|
||||
graph: {
|
||||
nodes: Array<Record<string, unknown>>
|
||||
edges: Array<Record<string, unknown>>
|
||||
viewport: { x: number; y: number; zoom: number }
|
||||
}
|
||||
features: Record<string, unknown>
|
||||
} | null
|
||||
isLoading: boolean
|
||||
fileUploadConfigResponse: Record<string, unknown> | null
|
||||
}
|
||||
const fetchDraft = vi.fn()
|
||||
|
||||
let consoleState: {
|
||||
isLoadingCurrentWorkspace: boolean
|
||||
@@ -105,72 +97,59 @@ vi.mock('@/next/navigation', async (importOriginal) => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Draft loading has its own suite; this owner integrates ready/loading data, replay and trigger status.
|
||||
vi.mock('../hooks/use-workflow-init', () => ({ useWorkflowInit: () => workflowInitState }))
|
||||
vi.mock('@/service/workflow', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/workflow')>()),
|
||||
fetchWorkflowDraft: (...args: unknown[]) => fetchDraft(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'files.upload.get': () => createFileUploadConfigFixture(),
|
||||
'apps.byAppId.workflows.defaultWorkflowBlockConfigs.get': () => [],
|
||||
'apps.byAppId.workflows.publish.get': () => createWorkflowResponseFixture(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({
|
||||
nodes,
|
||||
edges,
|
||||
children,
|
||||
}: {
|
||||
nodes: Array<Record<string, unknown>>
|
||||
edges: Array<Record<string, unknown>>
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
data-testid="workflow-default-context"
|
||||
data-nodes={JSON.stringify(nodes)}
|
||||
data-edges={JSON.stringify(edges)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
|
||||
default: function WorkflowMainMock({
|
||||
nodes,
|
||||
edges,
|
||||
// Full editor interactions have their own suites; this probe observes the real initialized store.
|
||||
default: function WorkflowMainProbe({
|
||||
viewport,
|
||||
}: {
|
||||
nodes: Array<Record<string, unknown>>
|
||||
edges: Array<Record<string, unknown>>
|
||||
viewport: Record<string, unknown>
|
||||
viewport: { x: number; y: number; zoom: number }
|
||||
}) {
|
||||
workflowStore = useWorkflowStore()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Workflow canvas"
|
||||
data-nodes={JSON.stringify(nodes)}
|
||||
data-edges={JSON.stringify(edges)}
|
||||
data-viewport={JSON.stringify(viewport)}
|
||||
/>
|
||||
<section aria-label="Workflow canvas">
|
||||
<WorkflowInitializationProbe />
|
||||
<output aria-label="Saved viewport">{JSON.stringify(viewport)}</output>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('WorkflowApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useTriggerStatusStore.getState().clearTriggerStatuses()
|
||||
appDetail = createAppDetailFixture({
|
||||
id: 'app-1',
|
||||
mode: 'workflow' as const,
|
||||
permission_keys: [AppACLPermission.TestAndRun],
|
||||
})
|
||||
workflowInitState = {
|
||||
data: {
|
||||
fetchDraft.mockResolvedValue(
|
||||
createWorkflowResponseFixture({
|
||||
hash: 'loaded-draft',
|
||||
graph: {
|
||||
nodes: [
|
||||
createNode({ id: 'raw-node', data: { type: BlockEnum.Start } }),
|
||||
createNode({ id: 'end-node', data: { type: BlockEnum.End } }),
|
||||
createNode({ id: 'raw-node', data: { type: BlockEnum.Start, variables: [] } }),
|
||||
createNode({ id: 'end-node', data: { type: BlockEnum.End, outputs: [] } }),
|
||||
],
|
||||
edges: [
|
||||
createEdge({
|
||||
@@ -180,17 +159,11 @@ describe('WorkflowApp', () => {
|
||||
data: { targetType: BlockEnum.End },
|
||||
}),
|
||||
],
|
||||
viewport: { x: 1, y: 2, zoom: 3 },
|
||||
viewport: { x: 1, y: 2, zoom: 1.5 },
|
||||
},
|
||||
features: {
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
fileUploadConfigResponse: createFileUploadConfigFixture(),
|
||||
}
|
||||
features: { file_upload: { enabled: true } },
|
||||
}),
|
||||
)
|
||||
consoleState = {
|
||||
isLoadingCurrentWorkspace: false,
|
||||
currentWorkspace: { id: 'workspace-1' },
|
||||
@@ -202,20 +175,36 @@ describe('WorkflowApp', () => {
|
||||
mockFetchRunDetail.mockReset().mockResolvedValue({ inputs: null })
|
||||
})
|
||||
|
||||
it('should render the loading shell while workflow data is still loading', () => {
|
||||
workflowInitState = {
|
||||
data: null,
|
||||
isLoading: true,
|
||||
fileUploadConfigResponse: null,
|
||||
}
|
||||
|
||||
it('keeps the canvas gated until the workflow draft arrives', async () => {
|
||||
let resolveDraft!: (value: ReturnType<typeof createWorkflowResponseFixture>) => void
|
||||
fetchDraft.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveDraft = resolve
|
||||
}),
|
||||
)
|
||||
render(<WorkflowApp />)
|
||||
|
||||
expect(screen.getByLabelText('common.loading')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Workflow canvas' })).not.toBeInTheDocument()
|
||||
|
||||
resolveDraft(
|
||||
createWorkflowResponseFixture({
|
||||
graph: {
|
||||
nodes: [
|
||||
createNode({ id: 'loaded-start', data: { type: BlockEnum.Start, variables: [] } }),
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByRole('region', { name: 'Workflow canvas' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('status', { name: 'Initialized nodes' })).toHaveTextContent(
|
||||
'loaded-start:Node loaded-start',
|
||||
)
|
||||
expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
|
||||
it('initializes the graph and trigger statuses in the real workflow providers', async () => {
|
||||
appTriggersState = {
|
||||
data: {
|
||||
data: [
|
||||
@@ -225,23 +214,24 @@ describe('WorkflowApp', () => {
|
||||
},
|
||||
}
|
||||
|
||||
render(<WorkflowApp />)
|
||||
render(
|
||||
<StrictMode>
|
||||
<WorkflowApp />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
const graphContext = screen.getByTestId('workflow-default-context')
|
||||
expect(JSON.parse(graphContext.getAttribute('data-nodes')!)).toMatchObject([
|
||||
{ id: 'raw-node', data: { type: BlockEnum.Start, _connectedSourceHandleIds: ['source'] } },
|
||||
{ id: 'end-node', data: { type: BlockEnum.End, _connectedTargetHandleIds: ['target'] } },
|
||||
])
|
||||
expect(JSON.parse(graphContext.getAttribute('data-edges')!)).toMatchObject([
|
||||
{
|
||||
source: 'raw-node',
|
||||
target: 'end-node',
|
||||
data: { sourceType: BlockEnum.Start, targetType: BlockEnum.End },
|
||||
},
|
||||
])
|
||||
expect(screen.getByRole('region', { name: 'Workflow canvas' })).toHaveAttribute(
|
||||
'data-viewport',
|
||||
JSON.stringify({ x: 1, y: 2, zoom: 3 }),
|
||||
await screen.findByRole('region', { name: 'Workflow canvas' })
|
||||
expect(await screen.findByRole('status', { name: 'Draft hash' })).toHaveTextContent(
|
||||
'loaded-draft',
|
||||
)
|
||||
expect(await screen.findByRole('status', { name: 'Initialized nodes' })).toHaveTextContent(
|
||||
'raw-node:Node raw-node,end-node:Node end-node',
|
||||
)
|
||||
expect(await screen.findByRole('status', { name: 'Initialized edges' })).toHaveTextContent(
|
||||
'raw-node->end-node',
|
||||
)
|
||||
expect(screen.getByLabelText('Saved viewport')).toHaveTextContent(
|
||||
JSON.stringify({ x: 1, y: 2, zoom: 1.5 }),
|
||||
)
|
||||
expect(useTriggerStatusStore.getState().triggerStatuses).toEqual({
|
||||
'trigger-enabled': 'enabled',
|
||||
@@ -256,7 +246,8 @@ describe('WorkflowApp', () => {
|
||||
'{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}',
|
||||
})
|
||||
|
||||
const { unmount } = render(<WorkflowApp />)
|
||||
render(<WorkflowApp />)
|
||||
await screen.findByRole('region', { name: 'Workflow canvas' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledWith('/apps/app-1/workflow-runs/run-1')
|
||||
@@ -270,12 +261,11 @@ describe('WorkflowApp', () => {
|
||||
expect(workflowStore.getState().showInputsPanel).toBe(true)
|
||||
expect(workflowStore.getState().showDebugAndPreviewPanel).toBe(true)
|
||||
})
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should skip replay lookups when replayRunId is missing', () => {
|
||||
it('should skip replay lookups when replayRunId is missing', async () => {
|
||||
render(<WorkflowApp />)
|
||||
await screen.findByRole('region', { name: 'Workflow canvas' })
|
||||
|
||||
expect(mockFetchRunDetail).not.toHaveBeenCalled()
|
||||
expect(workflowStore.getState().inputs).toEqual({})
|
||||
@@ -307,6 +297,7 @@ describe('WorkflowApp', () => {
|
||||
})
|
||||
|
||||
render(<WorkflowApp />)
|
||||
await screen.findByRole('region', { name: 'Workflow canvas' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledWith('/apps/app-1/workflow-runs/run-1')
|
||||
@@ -330,6 +321,7 @@ describe('WorkflowApp', () => {
|
||||
})
|
||||
|
||||
render(<WorkflowApp />)
|
||||
await screen.findByRole('region', { name: 'Workflow canvas' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledWith('/apps/app-1/workflow-runs/run-1')
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useStore } from '../store'
|
||||
// graph initialized by the real provider instead of capturing provider props in a mock.
|
||||
export function WorkflowInitializationProbe() {
|
||||
const graph = useStore((state) => state.workflowHistory)
|
||||
const draftHash = useStore((state) => state.syncWorkflowDraftHash)
|
||||
|
||||
return (
|
||||
<>
|
||||
<output aria-label="Initialized nodes">
|
||||
@@ -12,6 +14,7 @@ export function WorkflowInitializationProbe() {
|
||||
<output aria-label="Initialized edges">
|
||||
{graph.edges.map((edge) => `${edge.source}->${edge.target}`).join(',')}
|
||||
</output>
|
||||
<output aria-label="Draft hash">{draftHash}</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user