mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): replace AP1/AP3/AP11/AP30/AP32 test doubles (#45197)
This commit is contained in:
1 parent
8ba1adfbc4
commit
dcb332693e
4 files changed
+566
-683
No files matched your search
+245
-312
@@ -1,18 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TextGenerationTranslate } from '../../../types'
|
||||
import type { ResultInputValue } from '../../result-request'
|
||||
import type {
|
||||
createWorkflowEventsSubscription,
|
||||
WorkflowEventsHandlers,
|
||||
} from '../../workflow-events-subscription'
|
||||
import type { createWorkflowStreamHandlers } from '../../workflow-stream-handlers'
|
||||
import type { ResultRunStateController } from '../use-result-run-state'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { AppSourceType } from '@/service/share'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { AppSourceType as AppSourceTypeEnum } from '@/service/share'
|
||||
import type {
|
||||
HumanInputFormData,
|
||||
WorkflowFinishedResponse,
|
||||
WorkflowPausedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { createElement, useLayoutEffect } from 'react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
|
||||
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
|
||||
// oxlint-disable-next-line no-restricted-imports -- The test drives the production continuation transport callback boundary.
|
||||
import { sseGet } from '@/service/base'
|
||||
import { AppSourceType, sendWorkflowMessage } from '@/service/share'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { useResultRunState as useResultRunController } from '../use-result-run-state'
|
||||
import { useResultSender } from '../use-result-sender'
|
||||
|
||||
const releaseGate = vi.hoisted(() => ({ enabled: true }))
|
||||
@@ -23,165 +31,31 @@ vi.mock('@/config', async (importOriginal) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const {
|
||||
buildResultRequestDataMock,
|
||||
createWorkflowEventsSubscriptionMock,
|
||||
createWorkflowStreamHandlersMock,
|
||||
mockTrackEvent,
|
||||
mockWebAppState,
|
||||
sendCompletionMessageMock,
|
||||
sendWorkflowMessageMock,
|
||||
sleepMock,
|
||||
validateResultRequestMock,
|
||||
workflowEventsSubscription,
|
||||
} = vi.hoisted(() => ({
|
||||
buildResultRequestDataMock: vi.fn(),
|
||||
createWorkflowEventsSubscriptionMock: vi.fn(),
|
||||
createWorkflowStreamHandlersMock: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
mockWebAppState: {
|
||||
appInfo: {
|
||||
mode: 'completion',
|
||||
},
|
||||
},
|
||||
sendCompletionMessageMock: vi.fn(),
|
||||
sendWorkflowMessageMock: vi.fn(),
|
||||
sleepMock: vi.fn(),
|
||||
validateResultRequestMock: vi.fn(),
|
||||
workflowEventsSubscription: {
|
||||
start: vi.fn(),
|
||||
prepare: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendWorkflowMessage: vi.fn(),
|
||||
sseGet: vi.fn(),
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/share')>()),
|
||||
sendWorkflowMessage: mocks.sendWorkflowMessage,
|
||||
}))
|
||||
vi.mock('@/service/base', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/base')>()),
|
||||
sseGet: mocks.sseGet,
|
||||
}))
|
||||
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({
|
||||
trackWebAppEvent: mockTrackEvent,
|
||||
trackWebAppEvent: mocks.trackEvent,
|
||||
}))
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) =>
|
||||
selector(mockWebAppState),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
|
||||
return {
|
||||
...actual,
|
||||
sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) =>
|
||||
sendCompletionMessageMock(...args),
|
||||
sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) =>
|
||||
sendWorkflowMessageMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/utils')>('@/utils')
|
||||
return {
|
||||
...actual,
|
||||
sleep: (...args: Parameters<typeof actual.sleep>) => sleepMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../result-request', () => ({
|
||||
buildResultRequestData: (...args: unknown[]) => buildResultRequestDataMock(...args),
|
||||
validateResultRequest: (...args: unknown[]) => validateResultRequestMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-stream-handlers', async () => ({
|
||||
...(await vi.importActual<typeof import('../../workflow-stream-handlers')>(
|
||||
'../../workflow-stream-handlers',
|
||||
)),
|
||||
createWorkflowStreamHandlers: (...args: unknown[]) => createWorkflowStreamHandlersMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-events-subscription', () => ({
|
||||
createWorkflowEventsSubscription: (...args: unknown[]) =>
|
||||
createWorkflowEventsSubscriptionMock(...args),
|
||||
}))
|
||||
|
||||
type RunStateHarness = {
|
||||
state: {
|
||||
completionRes: string
|
||||
currentTaskId: string | null
|
||||
messageId: string | null
|
||||
workflowProcessData: ResultRunStateController['workflowProcessData']
|
||||
}
|
||||
runState: ResultRunStateController
|
||||
}
|
||||
|
||||
type WorkflowHandlerOptions = Parameters<typeof createWorkflowStreamHandlers>[0]
|
||||
|
||||
const createRunStateHarness = (): RunStateHarness => {
|
||||
const state: RunStateHarness['state'] = {
|
||||
completionRes: '',
|
||||
currentTaskId: null,
|
||||
messageId: null,
|
||||
workflowProcessData: undefined,
|
||||
}
|
||||
|
||||
const runState: ResultRunStateController = {
|
||||
abortControllerRef: { current: null },
|
||||
clearMoreLikeThis: vi.fn(),
|
||||
completionRes: '',
|
||||
controlClearMoreLikeThis: 0,
|
||||
currentTaskId: null,
|
||||
feedback: { rating: null },
|
||||
getCompletionRes: vi.fn(() => state.completionRes),
|
||||
getWorkflowProcessData: vi.fn(() => state.workflowProcessData),
|
||||
handleFeedback: vi.fn(),
|
||||
handleStop: vi.fn(),
|
||||
isResponding: false,
|
||||
isStopping: false,
|
||||
messageId: null,
|
||||
prepareForNewRun: vi.fn(() => {
|
||||
state.completionRes = ''
|
||||
state.currentTaskId = null
|
||||
state.messageId = null
|
||||
state.workflowProcessData = undefined
|
||||
runState.completionRes = ''
|
||||
runState.currentTaskId = null
|
||||
runState.messageId = null
|
||||
runState.workflowProcessData = undefined
|
||||
}),
|
||||
resetRunState: vi.fn(() => {
|
||||
state.currentTaskId = null
|
||||
runState.currentTaskId = null
|
||||
runState.isStopping = false
|
||||
}),
|
||||
setCompletionRes: vi.fn((value: string) => {
|
||||
state.completionRes = value
|
||||
runState.completionRes = value
|
||||
}),
|
||||
setCurrentTaskId: vi.fn((value) => {
|
||||
state.currentTaskId = typeof value === 'function' ? value(state.currentTaskId) : value
|
||||
runState.currentTaskId = state.currentTaskId
|
||||
}),
|
||||
setIsStopping: vi.fn((value) => {
|
||||
runState.isStopping = typeof value === 'function' ? value(runState.isStopping) : value
|
||||
}),
|
||||
setMessageId: vi.fn((value) => {
|
||||
state.messageId = typeof value === 'function' ? value(state.messageId) : value
|
||||
runState.messageId = state.messageId
|
||||
}),
|
||||
setRespondingFalse: vi.fn(() => {
|
||||
runState.isResponding = false
|
||||
}),
|
||||
setRespondingTrue: vi.fn(() => {
|
||||
runState.isResponding = true
|
||||
}),
|
||||
setWorkflowProcessData: vi.fn((value) => {
|
||||
state.workflowProcessData = value
|
||||
runState.workflowProcessData = value
|
||||
}),
|
||||
workflowProcessData: undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
runState,
|
||||
}
|
||||
}
|
||||
const workflowTransport = vi.mocked(sendWorkflowMessage)
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- The production continuation still uses the SSE transport.
|
||||
const workflowEvents = vi.mocked(sseGet)
|
||||
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
@@ -195,6 +69,66 @@ const visionConfig: VisionSettings = {
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
}
|
||||
|
||||
const appInfo: AppData = {
|
||||
app_id: 'app-1',
|
||||
mode: 'completion',
|
||||
custom_config: null,
|
||||
site: { title: 'Result App' },
|
||||
}
|
||||
|
||||
const form: HumanInputFormData = {
|
||||
form_id: 'form-1',
|
||||
node_id: 'node-1',
|
||||
node_title: 'Approval',
|
||||
form_content: 'Please review',
|
||||
inputs: [],
|
||||
actions: [],
|
||||
form_token: 'token-1',
|
||||
resolved_default_values: {},
|
||||
display_in_ui: true,
|
||||
expiration_time: 100,
|
||||
}
|
||||
|
||||
const paused: WorkflowPausedResponse = {
|
||||
event: 'workflow_paused',
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
data: {
|
||||
workflow_run_id: 'run-1',
|
||||
outputs: {},
|
||||
paused_nodes: ['node-1'],
|
||||
reasons: [],
|
||||
},
|
||||
}
|
||||
|
||||
const finished: WorkflowFinishedResponse = {
|
||||
event: 'workflow_finished',
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
data: {
|
||||
id: 'run-1',
|
||||
workflow_id: 'workflow-1',
|
||||
status: 'succeeded',
|
||||
outputs: { answer: 'Approved' },
|
||||
error: '',
|
||||
elapsed_time: 1,
|
||||
total_tokens: 0,
|
||||
total_steps: 3,
|
||||
created_at: 0,
|
||||
finished_at: 1,
|
||||
created_by: { id: 'user-1', name: 'User', email: 'user@example.com' },
|
||||
},
|
||||
}
|
||||
|
||||
const InitialWebAppData = ({ children, data }: { children?: ReactNode; data: AppData }) => {
|
||||
const updateAppInfo = useWebAppStore((state) => state.updateAppInfo)
|
||||
useLayoutEffect(() => {
|
||||
updateAppInfo(data)
|
||||
return () => updateAppInfo(null)
|
||||
}, [data, updateAppInfo])
|
||||
return children
|
||||
}
|
||||
|
||||
type RenderSenderOptions = {
|
||||
appSourceType?: AppSourceType
|
||||
controlRetry?: number
|
||||
@@ -202,28 +136,42 @@ type RenderSenderOptions = {
|
||||
inputs?: Record<string, ResultInputValue>
|
||||
isPC?: boolean
|
||||
isWorkflow?: boolean
|
||||
runState?: ResultRunStateController
|
||||
taskId?: number
|
||||
}
|
||||
|
||||
const renderSender = ({
|
||||
appSourceType = AppSourceTypeEnum.webApp,
|
||||
appSourceType = AppSourceType.webApp,
|
||||
controlRetry = 0,
|
||||
controlSend = 0,
|
||||
inputs = { name: 'Alice' },
|
||||
isPC = true,
|
||||
isWorkflow = false,
|
||||
runState,
|
||||
isWorkflow = true,
|
||||
taskId,
|
||||
}: RenderSenderOptions = {}) => {
|
||||
const notify = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const onRunStart = vi.fn()
|
||||
const onShowRes = vi.fn()
|
||||
|
||||
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
QueryWrapper,
|
||||
null,
|
||||
createElement(
|
||||
WebAppStoreProvider,
|
||||
null,
|
||||
createElement(InitialWebAppData, { data: appInfo }, children),
|
||||
),
|
||||
)
|
||||
const hook = renderHook(
|
||||
(props: { controlRetry: number; controlSend: number }) =>
|
||||
useResultSender({
|
||||
(props: { controlRetry: number; controlSend: number }) => {
|
||||
const state = useResultRunController({
|
||||
appId: 'app-1',
|
||||
appSourceType,
|
||||
isWorkflow,
|
||||
notify,
|
||||
})
|
||||
const sender = useResultSender({
|
||||
appId: 'app-1',
|
||||
appSourceType,
|
||||
completionFiles: [],
|
||||
@@ -238,188 +186,173 @@ const renderSender = ({
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
promptConfig,
|
||||
runState: runState || createRunStateHarness().runState,
|
||||
runState: state,
|
||||
t: withSelectorKey((key: string) => key) as TextGenerationTranslate,
|
||||
taskId,
|
||||
visionConfig,
|
||||
}),
|
||||
})
|
||||
return { ...sender, state }
|
||||
},
|
||||
{
|
||||
initialProps: {
|
||||
controlRetry,
|
||||
controlSend,
|
||||
},
|
||||
initialProps: { controlRetry, controlSend },
|
||||
wrapper,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
...hook,
|
||||
notify,
|
||||
onCompleted,
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
}
|
||||
return { ...hook, notify, onCompleted, onRunStart, onShowRes }
|
||||
}
|
||||
|
||||
describe('useResultSender', () => {
|
||||
const initialHandlers = (run = 0) => {
|
||||
const call = workflowTransport.mock.calls[run]
|
||||
if (!call) throw new Error(`Missing workflow transport ${run}`)
|
||||
return call[1]
|
||||
}
|
||||
|
||||
const continuationHandlers = (run = 0) => {
|
||||
const call = workflowEvents.mock.calls[run]
|
||||
if (!call) throw new Error(`Missing workflow continuation ${run}`)
|
||||
return call[2]
|
||||
}
|
||||
|
||||
const pausePublicRun = (run = 0) => {
|
||||
const handlers = initialHandlers(run)
|
||||
act(() => {
|
||||
handlers.onWorkflowStarted?.({
|
||||
workflow_run_id: `run-${run + 1}`,
|
||||
task_id: `task-${run + 1}`,
|
||||
event: 'workflow_started',
|
||||
data: { id: `run-${run + 1}`, workflow_id: 'workflow-1', created_at: 0 },
|
||||
})
|
||||
handlers.onHumanInputRequired?.({
|
||||
event: 'human_input_required',
|
||||
task_id: `task-${run + 1}`,
|
||||
workflow_run_id: `run-${run + 1}`,
|
||||
data: { ...form, form_id: `form-${run + 1}` },
|
||||
})
|
||||
handlers.onWorkflowPaused?.({
|
||||
...paused,
|
||||
task_id: `task-${run + 1}`,
|
||||
workflow_run_id: `run-${run + 1}`,
|
||||
data: { ...paused.data, workflow_run_id: `run-${run + 1}` },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('useResultSender HITL workflow ownership', () => {
|
||||
beforeEach(() => {
|
||||
releaseGate.enabled = true
|
||||
vi.clearAllMocks()
|
||||
mockWebAppState.appInfo.mode = 'completion'
|
||||
validateResultRequestMock.mockReturnValue({ canSend: true })
|
||||
buildResultRequestDataMock.mockReturnValue({ inputs: { name: 'Alice' } })
|
||||
createWorkflowStreamHandlersMock.mockReturnValue({ onWorkflowFinished: vi.fn() })
|
||||
createWorkflowEventsSubscriptionMock.mockReturnValue(workflowEventsSubscription)
|
||||
workflowEventsSubscription.prepare.mockResolvedValue(true)
|
||||
sendCompletionMessageMock.mockResolvedValue(undefined)
|
||||
sendWorkflowMessageMock.mockResolvedValue(undefined)
|
||||
sleepMock.mockImplementation(() => new Promise<void>(() => {}))
|
||||
mocks.sendWorkflowMessage.mockResolvedValue(undefined)
|
||||
mocks.sseGet.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it.each([AppSourceTypeEnum.installedApp, AppSourceTypeEnum.tryApp])(
|
||||
'should keep %s workflow handlers on the existing non-public path',
|
||||
it.each([AppSourceType.installedApp, AppSourceType.tryApp])(
|
||||
'keeps %s workflow events on the existing non-public route',
|
||||
async (appSourceType) => {
|
||||
const harness = createRunStateHarness()
|
||||
const { result } = renderSender({ appSourceType })
|
||||
await act(async () => expect(await result.current.handleSend()).toBe(true))
|
||||
|
||||
const { result } = renderSender({
|
||||
appSourceType,
|
||||
isWorkflow: true,
|
||||
runState: harness.runState,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current.handleSend()).toBe(true)
|
||||
})
|
||||
|
||||
expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isPublicAPI: false,
|
||||
}),
|
||||
)
|
||||
expect(sendWorkflowMessageMock).toHaveBeenCalledWith(
|
||||
expect(workflowTransport).toHaveBeenCalledExactlyOnceWith(
|
||||
{ inputs: { name: 'Alice' } },
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ isPublicAPI: false }),
|
||||
appSourceType,
|
||||
'app-1',
|
||||
)
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(createWorkflowEventsSubscriptionMock).not.toHaveBeenCalled()
|
||||
expect(mocks.trackEvent).not.toHaveBeenCalled()
|
||||
expect(workflowEvents).not.toHaveBeenCalled()
|
||||
act(() => initialHandlers().onWorkflowFinished?.(finished))
|
||||
expect(result.current.state.workflowProcessData?.status).toBe(WorkflowRunningStatus.Succeeded)
|
||||
},
|
||||
)
|
||||
|
||||
it('should keep a paused approval available after the generation timeout has elapsed', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let expireGenerationTimeout: (() => void) | undefined
|
||||
let markPausedTransportEnded: (() => void) | undefined
|
||||
let isTimedOut: (() => boolean) | undefined
|
||||
it('keeps a paused approval available after the real generation timeout elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { result, onCompleted } = renderSender()
|
||||
await act(async () => result.current.handleSend())
|
||||
pausePublicRun()
|
||||
|
||||
sleepMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
expireGenerationTimeout = resolve
|
||||
await act(async () => vi.advanceTimersByTimeAsync(TEXT_GENERATION_TIMEOUT_MS))
|
||||
|
||||
expect(result.current.state.workflowProcessData).toEqual(
|
||||
expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Paused,
|
||||
humanInputFormDataList: [form],
|
||||
}),
|
||||
)
|
||||
createWorkflowStreamHandlersMock.mockImplementation((options: WorkflowHandlerOptions) => {
|
||||
markPausedTransportEnded = options.markEnded
|
||||
isTimedOut = options.isTimedOut
|
||||
return {}
|
||||
})
|
||||
const { result, onCompleted } = renderSender({
|
||||
isWorkflow: true,
|
||||
runState: harness.runState,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
markPausedTransportEnded?.()
|
||||
expireGenerationTimeout?.()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(isTimedOut?.()).toBe(false)
|
||||
expect(harness.runState.resetRunState).not.toHaveBeenCalled()
|
||||
expect(onCompleted).not.toHaveBeenCalled()
|
||||
expect(workflowEventsSubscription.dispose).not.toHaveBeenCalled()
|
||||
)
|
||||
expect(result.current.state.isResponding).toBe(true)
|
||||
expect(onCompleted).not.toHaveBeenCalled()
|
||||
expect(workflowEvents).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should release a public workflow subscription before a new run and on unmount', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let onPaused: ((workflowRunId: string, handlers: WorkflowEventsHandlers) => void) | undefined
|
||||
createWorkflowStreamHandlersMock.mockImplementation((options: WorkflowHandlerOptions) => {
|
||||
onPaused = options.onPaused
|
||||
return { isPublicAPI: options.isPublicAPI }
|
||||
})
|
||||
const { result, unmount } = renderSender({
|
||||
isWorkflow: true,
|
||||
runState: harness.runState,
|
||||
})
|
||||
it('waits for event readiness and disposes each public continuation before reuse or unmount', async () => {
|
||||
const { result, unmount } = renderSender()
|
||||
await act(async () => result.current.handleSend())
|
||||
pausePublicRun()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
let ready: boolean | undefined
|
||||
const readiness = result.current.prepareHumanInputSubmission().then((value) => {
|
||||
ready = value
|
||||
})
|
||||
expect(createWorkflowEventsSubscriptionMock).toHaveBeenCalledOnce()
|
||||
const streamHandlers: WorkflowEventsHandlers = { isPublicAPI: true }
|
||||
onPaused?.('run-1', streamHandlers)
|
||||
expect(workflowEventsSubscription.start).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
expect.objectContaining({
|
||||
isPublicAPI: true,
|
||||
getAbortController: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
expect(await result.current.prepareHumanInputSubmission()).toBe(true)
|
||||
expect(workflowEventsSubscription.prepare).toHaveBeenCalledOnce()
|
||||
await act(async () => Promise.resolve())
|
||||
expect(ready).toBeUndefined()
|
||||
|
||||
harness.runState.isResponding = false
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
const firstController = new AbortController()
|
||||
act(() => {
|
||||
continuationHandlers().getAbortController?.(firstController)
|
||||
continuationHandlers().onWorkflowPaused?.(paused)
|
||||
})
|
||||
expect(workflowEventsSubscription.dispose).toHaveBeenCalledOnce()
|
||||
expect(createWorkflowEventsSubscriptionMock).toHaveBeenCalledTimes(2)
|
||||
await act(async () => readiness)
|
||||
expect(ready).toBe(true)
|
||||
|
||||
act(() => continuationHandlers().onWorkflowFinished?.(finished))
|
||||
await waitFor(() => expect(result.current.state.isResponding).toBe(false))
|
||||
expect(firstController.signal.aborted).toBe(true)
|
||||
|
||||
await act(async () => expect(await result.current.handleSend()).toBe(true))
|
||||
pausePublicRun(1)
|
||||
const secondController = new AbortController()
|
||||
act(() => continuationHandlers(1).getAbortController?.(secondController))
|
||||
unmount()
|
||||
expect(workflowEventsSubscription.dispose).toHaveBeenCalledTimes(2)
|
||||
expect(secondController.signal.aborted).toBe(true)
|
||||
expect(workflowTransport).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases a stopped public run and lets the user start another run', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let onAbort: (() => void) | undefined
|
||||
createWorkflowEventsSubscriptionMock.mockImplementation(
|
||||
(options: Parameters<typeof createWorkflowEventsSubscription>[0]) => {
|
||||
onAbort = options?.onAbort
|
||||
return workflowEventsSubscription
|
||||
},
|
||||
const { result, onCompleted } = renderSender()
|
||||
await act(async () => result.current.handleSend())
|
||||
pausePublicRun()
|
||||
const controller = new AbortController()
|
||||
act(() => continuationHandlers().getAbortController?.(controller))
|
||||
act(() => controller.abort())
|
||||
|
||||
expect(result.current.state.isResponding).toBe(false)
|
||||
expect(result.current.state.workflowProcessData).toEqual(
|
||||
expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Stopped,
|
||||
humanInputFormDataList: [],
|
||||
}),
|
||||
)
|
||||
const { result, onCompleted } = renderSender({ isWorkflow: true, runState: harness.runState })
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
await act(async () => {
|
||||
onAbort?.()
|
||||
})
|
||||
expect(harness.runState.isResponding).toBe(false)
|
||||
expect(harness.runState.getWorkflowProcessData()).toMatchObject({
|
||||
status: 'stopped',
|
||||
humanInputFormDataList: [],
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith('', undefined, false)
|
||||
await act(async () => {
|
||||
expect(await result.current.handleSend()).toBe(true)
|
||||
})
|
||||
expect(sendWorkflowMessageMock).toHaveBeenCalledTimes(2)
|
||||
await act(async () => expect(await result.current.handleSend()).toBe(true))
|
||||
expect(workflowTransport).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('uses the legacy public stream without creating an IM subscription when disabled', async () => {
|
||||
|
||||
it('uses the legacy public stream without creating a continuation when disabled', async () => {
|
||||
releaseGate.enabled = false
|
||||
const harness = createRunStateHarness()
|
||||
const { result } = renderSender({ isWorkflow: true, runState: harness.runState })
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith(
|
||||
const { result } = renderSender()
|
||||
await act(async () => result.current.handleSend())
|
||||
|
||||
expect(workflowTransport).toHaveBeenCalledExactlyOnceWith(
|
||||
{ inputs: { name: 'Alice' } },
|
||||
expect.objectContaining({ isPublicAPI: true }),
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
)
|
||||
expect(createWorkflowStreamHandlersMock.mock.calls[0]?.[0].onPaused).toBeUndefined()
|
||||
expect(createWorkflowEventsSubscriptionMock).not.toHaveBeenCalled()
|
||||
expect(workflowEvents).not.toHaveBeenCalled()
|
||||
expect(await result.current.prepareHumanInputSubmission()).toBe(true)
|
||||
expect(workflowEventsSubscription.prepare).not.toHaveBeenCalled()
|
||||
act(() => initialHandlers().onWorkflowFinished?.(finished))
|
||||
expect(result.current.state.workflowProcessData?.status).toBe(WorkflowRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
@@ -4,28 +4,27 @@ import type {
|
||||
ListContactOptionsResponse,
|
||||
ListContactsResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ContactRecipientOption, ContactRecipientOptionProvider } from '../contact-provider'
|
||||
import type { HumanInputV2Recipient } from '../types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { zGetFeaturesResponse } from '@dify/contracts/api/console/features/zod.gen'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render as renderComponent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { useState } from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
createEdge,
|
||||
createNode,
|
||||
createStartNode,
|
||||
} from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { seedModelProviderQueries } from '@/test/console/model-provider'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { seedToolProviders } from '@/test/console/tool-providers'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import { renderWorkflowEditor } from '@/test/workflow-editor'
|
||||
import Recipients from '../components/recipients'
|
||||
|
||||
const runtimeApi = vi.hoisted(() => ({
|
||||
@@ -40,66 +39,25 @@ const runtimeApi = vi.hoisted(() => ({
|
||||
options: vi.fn<() => Promise<ListContactOptionsResponse>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async () => {
|
||||
const { createTanstackQueryUtils } = await import('@orpc/tanstack-query')
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
consoleQuery: createTanstackQueryUtils({
|
||||
features: { get: runtimeApi.features },
|
||||
workspaces: {
|
||||
current: {
|
||||
humanInput: {
|
||||
v2: {
|
||||
contacts: { get: runtimeApi.contacts },
|
||||
contactOptions: { get: runtimeApi.options },
|
||||
},
|
||||
},
|
||||
summary: { get: runtimeApi.summary },
|
||||
},
|
||||
},
|
||||
...actual,
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'features.get': runtimeApi.features,
|
||||
'workspaces.current.humanInput.v2.contacts.get': runtimeApi.contacts,
|
||||
'workspaces.current.humanInput.v2.contactOptions.get': runtimeApi.options,
|
||||
'workspaces.current.summary.get': runtimeApi.summary,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
__esModule: true,
|
||||
default: function MockVarReferencePicker(props: {
|
||||
onChange: (value: string[]) => void
|
||||
trigger?: ReactElement
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const variable = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="insert-dynamic-recipient"
|
||||
onClick={() => {
|
||||
props.onChange(['start', 'owner_email'])
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
return props.trigger ? (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger render={props.trigger} />
|
||||
<PopoverContent>{variable}</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
variable
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const createFeaturesFixture = (centrallyManaged?: boolean): GetFeaturesResponse => ({
|
||||
...zGetFeaturesResponse.parse({}),
|
||||
...(centrallyManaged === undefined ? {} : { im_centrally_managed: centrallyManaged }),
|
||||
})
|
||||
|
||||
const render = (ui: ReactElement, queryClient = createTestQueryClient()) =>
|
||||
renderComponent(ui, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
|
||||
const contact: ContactRecipientOption = {
|
||||
id: 'contact-evan',
|
||||
name: 'Evan Zhang',
|
||||
@@ -151,9 +109,48 @@ const Harness = ({
|
||||
)
|
||||
}
|
||||
|
||||
const startNode = createStartNode({
|
||||
id: 'start',
|
||||
data: {
|
||||
title: 'Start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'owner_email',
|
||||
label: 'Owner email',
|
||||
type: InputVarType.textInput,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const recipientNode = createNode({
|
||||
id: 'human-input-v2',
|
||||
data: { title: 'Approval recipients', type: BlockEnum.HumanInput },
|
||||
})
|
||||
const prepareEditorQueryClient = (queryClient: QueryClient = createTestQueryClient()) => {
|
||||
createConsoleQueryWrapper({ queryClient })
|
||||
seedModelProviderQueries(queryClient)
|
||||
seedToolProviders(queryClient, {
|
||||
builtInTools: [],
|
||||
customTools: [],
|
||||
workflowTools: [],
|
||||
mcpTools: [],
|
||||
})
|
||||
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
|
||||
// Start the real feature request before workflow-provider defaults are seeded to preserve pending and error scenarios.
|
||||
void queryClient.query(consoleQuery.features.get.queryOptions()).catch(() => undefined)
|
||||
return queryClient
|
||||
}
|
||||
const render = (ui: ReactElement, queryClient?: QueryClient) =>
|
||||
renderWorkflowEditor(ui, {
|
||||
queryClient: prepareEditorQueryClient(queryClient),
|
||||
resource: { kind: 'app', appId: 'app-1', mode: 'workflow' },
|
||||
nodes: [startNode, recipientNode],
|
||||
edges: [createEdge({ source: startNode.id, target: recipientNode.id })],
|
||||
})
|
||||
|
||||
describe('Human Input v2 Recipients', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeApi.features.mockReset().mockResolvedValue(createFeaturesFixture(true))
|
||||
runtimeApi.contacts.mockResolvedValue({ data: [], total: 7, page: 1, limit: 1 })
|
||||
runtimeApi.options.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 })
|
||||
@@ -167,8 +164,8 @@ describe('Human Input v2 Recipients', () => {
|
||||
screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.recipients.addContact' }),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(runtimeApi.features).toHaveBeenCalledOnce())
|
||||
expect(await screen.findByRole('tab', { name: 'contacts.type.platform' })).toBeInTheDocument()
|
||||
expect(runtimeApi.features).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, undefined])(
|
||||
@@ -433,25 +430,23 @@ describe('Human Input v2 Recipients', () => {
|
||||
it('opens variable insertion with slash only from an empty non-composing recipient input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const observe = vi.fn()
|
||||
render(<Harness observe={observe} />)
|
||||
render(<Harness observe={observe} availableNodes={[startNode]} />)
|
||||
const input = screen.getByLabelText('workflow.nodes.humanInputV2.recipients.placeholder')
|
||||
|
||||
await user.type(input, 'draft/')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'insert-dynamic-recipient' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('owner_email')).not.toBeInTheDocument()
|
||||
await user.clear(input)
|
||||
fireEvent.keyDown(input, { key: '/', isComposing: true })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'insert-dynamic-recipient' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('owner_email')).not.toBeInTheDocument()
|
||||
|
||||
await user.keyboard('/')
|
||||
expect(input).toHaveValue('')
|
||||
await user.click(screen.getByRole('button', { name: 'insert-dynamic-recipient' }))
|
||||
await user.click(await screen.findByText('owner_email'))
|
||||
expect(observe).toHaveBeenCalledExactlyOnceWith([
|
||||
{ type: 'dynamic_email', selector: ['start', 'owner_email'] },
|
||||
])
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
expect(screen.getByText('owner_email')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not add a previous search result when Enter is pressed after clearing the input', async () => {
|
||||
@@ -612,14 +607,9 @@ describe('Human Input v2 Recipients', () => {
|
||||
plan: null,
|
||||
credits: null,
|
||||
} satisfies CurrentWorkspaceSummaryResponse)
|
||||
const store = createStore()
|
||||
store.set(queryClientAtom, queryClient)
|
||||
render(
|
||||
<JotaiProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Recipients nodeId="runtime-node" value={[]} onChange={vi.fn()} readonly={false} />
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>,
|
||||
<Recipients nodeId="runtime-node" value={[]} onChange={vi.fn()} readonly={false} />,
|
||||
queryClient,
|
||||
)
|
||||
|
||||
await user.click(
|
||||
@@ -919,7 +909,7 @@ describe('Human Input v2 Recipients', () => {
|
||||
it('adds Contact, one-time email, Dynamic Email and Initiator in order', async () => {
|
||||
const user = userEvent.setup()
|
||||
const observe = vi.fn()
|
||||
render(<Harness observe={observe} />)
|
||||
render(<Harness observe={observe} availableNodes={[startNode]} />)
|
||||
|
||||
const emailInput = screen.getByLabelText('workflow.nodes.humanInputV2.recipients.placeholder')
|
||||
await user.type(emailInput, 'owner@example.com{Enter}')
|
||||
@@ -930,7 +920,7 @@ describe('Human Input v2 Recipients', () => {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.recipients.insertVariable' }),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'insert-dynamic-recipient' }))
|
||||
await user.click(within(await screen.findByRole('dialog')).getByText('owner_email'))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.recipients.addContact' }),
|
||||
)
|
||||
@@ -946,6 +936,8 @@ describe('Human Input v2 Recipients', () => {
|
||||
{ type: 'dynamic_email', selector: ['start', 'owner_email'] },
|
||||
{ type: 'initiator' },
|
||||
])
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
expect(screen.getByText('owner_email')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Recipient remove buttons', () => {
|
||||
|
||||
+145
-192
@@ -1,45 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HumanInputV2NodeType } from '../../../human-input-v2/types'
|
||||
import type { HumanInputNodeType } from '../../types'
|
||||
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store/store'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import {
|
||||
act,
|
||||
render as rtlRender,
|
||||
renderHook as rtlRenderHook,
|
||||
screen,
|
||||
} from '@testing-library/react'
|
||||
import { createElement, StrictMode, useEffect, useRef } from 'react'
|
||||
import { EditorResourceScope } from '@/app/components/workflow/editor-resource/provider'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement, StrictMode, useEffect, useRef, useState } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import { createNode } from '../../../../__tests__/fixtures'
|
||||
import {
|
||||
renderWorkflowCanvasHook,
|
||||
renderWorkflowFlowComponent,
|
||||
} 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 mockUseNodeCrud = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/notifications', () => ({ toast: { error: mockToastError } }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation() }))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}))
|
||||
type NodeData = HumanInputNodeType & Partial<Pick<HumanInputV2NodeType, 'version'>>
|
||||
|
||||
const mockSyncDraft = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockSyncDraft }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
const createPayload = (
|
||||
overrides: Partial<HumanInputNodeType & Pick<HumanInputV2NodeType, 'version'>> = {},
|
||||
): HumanInputNodeType & Partial<Pick<HumanInputV2NodeType, 'version'>> => ({
|
||||
const createPayload = (overrides: Partial<NodeData> = {}): NodeData => ({
|
||||
title: 'Human Input',
|
||||
desc: '',
|
||||
type: BlockEnum.HumanInput,
|
||||
@@ -49,11 +34,7 @@ const createPayload = (
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector: ['start', 'topic'],
|
||||
value: '',
|
||||
},
|
||||
default: { type: 'variable', selector: ['start', 'topic'], value: '' },
|
||||
},
|
||||
],
|
||||
user_actions: [],
|
||||
@@ -79,139 +60,131 @@ const mockFormData: HumanInputFormData = {
|
||||
inputs: [],
|
||||
actions: [],
|
||||
form_token: 'token-1',
|
||||
resolved_default_values: {
|
||||
topic: 'AI',
|
||||
},
|
||||
resolved_default_values: { topic: 'AI' },
|
||||
display_in_ui: true,
|
||||
expiration_time: 1000,
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
QueryWrapper,
|
||||
null,
|
||||
createElement(EditorResourceScope, {
|
||||
resource: { kind: 'app', appId: 'app-1', mode: 'workflow' },
|
||||
children,
|
||||
}),
|
||||
)
|
||||
const getInputVars = vi.fn(() => [
|
||||
createInputVar(),
|
||||
createInputVar({
|
||||
label: 'Output',
|
||||
variable: '#$output.answer#',
|
||||
value_selector: ['$output', 'answer'],
|
||||
}),
|
||||
])
|
||||
|
||||
type SubjectProps = { initialRunInputData?: Record<string, string> }
|
||||
|
||||
const renderSubject = (
|
||||
payload: NodeData,
|
||||
doSyncWorkflowDraft: HooksStoreShape['doSyncWorkflowDraft'] = vi.fn(async () => ({
|
||||
hash: 'saved',
|
||||
updatedAt: 1,
|
||||
})),
|
||||
props: SubjectProps = {},
|
||||
) =>
|
||||
renderWorkflowCanvasHook(
|
||||
({ initialRunInputData }: SubjectProps) => {
|
||||
const node = useNodes<NodeData>().find(({ id }) => id === 'node-1')!
|
||||
const [runInputData, setRunInputData] = useState(initialRunInputData ?? {})
|
||||
const form = useSingleRunFormParams({
|
||||
id: node.id,
|
||||
payload: node.data,
|
||||
runInputData,
|
||||
getInputVars,
|
||||
setRunInputData,
|
||||
})
|
||||
return { form, runInputData }
|
||||
},
|
||||
{
|
||||
initialProps: props,
|
||||
nodes: [createNode({ id: 'node-1', data: payload })],
|
||||
hooksStoreProps: { doSyncWorkflowDraft },
|
||||
resource: { kind: 'app', appId: 'app-1', mode: 'workflow' },
|
||||
},
|
||||
)
|
||||
|
||||
const setSingleRunOpen = (
|
||||
subject: Pick<ReturnType<typeof renderSubject>, 'setCanvas'>,
|
||||
payload: NodeData,
|
||||
open: boolean,
|
||||
) => {
|
||||
act(() =>
|
||||
subject.setCanvas({
|
||||
nodes: [createNode({ id: 'node-1', data: { ...payload, _isSingleRun: open } })],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const renderHook = <T>(callback: () => T) => rtlRenderHook(callback, { wrapper: createWrapper() })
|
||||
|
||||
const render = (ui: Parameters<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: createWrapper() })
|
||||
|
||||
describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
const mockSetRunInputData = vi.fn()
|
||||
const getInputVars = vi.fn()
|
||||
let currentInputs = createPayload()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSyncDraft.mockResolvedValue({})
|
||||
currentInputs = createPayload()
|
||||
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
})
|
||||
|
||||
mockUseNodeCrud.mockImplementation(() => ({
|
||||
inputs: currentInputs,
|
||||
}))
|
||||
getInputVars.mockReturnValue([
|
||||
createInputVar(),
|
||||
createInputVar({
|
||||
label: 'Output',
|
||||
variable: '#$output.answer#',
|
||||
value_selector: ['$output', 'answer'],
|
||||
}),
|
||||
{
|
||||
...createInputVar({
|
||||
label: 'Broken',
|
||||
}),
|
||||
variable: undefined,
|
||||
} as unknown as InputVar,
|
||||
])
|
||||
mockUseTranslation.mockReturnValue({ t: withSelectorKey((key: string) => key) })
|
||||
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(mockFormData)
|
||||
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('waits for the V2 draft save before requesting the authoritative form', async () => {
|
||||
currentInputs = createPayload({ version: '2' })
|
||||
let resolveSave!: (value: object) => void
|
||||
mockSyncDraft.mockImplementation(
|
||||
const payload = createPayload({ version: '2' })
|
||||
let resolveSave!: (value: { hash: string; updatedAt: number }) => void
|
||||
const syncDraft = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
new Promise<{ hash: string; updatedAt: number }>((resolve) => {
|
||||
resolveSave = resolve
|
||||
}),
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
}),
|
||||
)
|
||||
const subject = renderSubject(payload, syncDraft, {
|
||||
initialRunInputData: { topic: 'initial' },
|
||||
})
|
||||
|
||||
expect(subject.result.current.form.forms[0]).toMatchObject({
|
||||
inputs: [expect.objectContaining({ variable: '#start.topic#' })],
|
||||
values: { topic: 'initial' },
|
||||
})
|
||||
act(() => subject.result.current.form.forms[0]!.onChange({ topic: 'saved' }))
|
||||
expect(subject.result.current.runInputData).toEqual({ topic: 'saved' })
|
||||
|
||||
let request!: Promise<void>
|
||||
act(() => {
|
||||
request = result.current.handleShowGeneratedForm({ topic: 'saved' })
|
||||
request = subject.result.current.form.handleShowGeneratedForm(
|
||||
subject.result.current.runInputData,
|
||||
)
|
||||
})
|
||||
expect(result.current.isGeneratingForm).toBe(true)
|
||||
expect(subject.result.current.form.isGeneratingForm).toBe(true)
|
||||
expect(mockFetchHumanInputNodeStepRunForm).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
resolveSave({})
|
||||
resolveSave({ hash: 'saved', updatedAt: 1 })
|
||||
await request
|
||||
})
|
||||
expect(result.current.showGeneratedForm).toBe(true)
|
||||
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledOnce()
|
||||
expect(subject.result.current.form.showGeneratedForm).toBe(true)
|
||||
expect(subject.result.current.form.formData).toEqual(mockFormData)
|
||||
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledExactlyOnceWith(
|
||||
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
|
||||
{ inputs: { topic: 'saved' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('does not test stale V2 data when the draft save is skipped', async () => {
|
||||
currentInputs = createPayload({ version: '2' })
|
||||
mockSyncDraft.mockResolvedValue(null)
|
||||
const { result } = renderHook(() =>
|
||||
useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
}),
|
||||
const subject = renderSubject(
|
||||
createPayload({ version: '2' }),
|
||||
vi.fn(async () => null),
|
||||
)
|
||||
await act(async () => {
|
||||
await result.current.handleShowGeneratedForm({})
|
||||
})
|
||||
await act(async () => subject.result.current.form.handleShowGeneratedForm({}))
|
||||
expect(mockFetchHumanInputNodeStepRunForm).not.toHaveBeenCalled()
|
||||
expect(result.current.showGeneratedForm).toBe(false)
|
||||
expect(result.current.isGeneratingForm).toBe(false)
|
||||
expect(subject.result.current.form.showGeneratedForm).toBe(false)
|
||||
expect(subject.result.current.form.isGeneratingForm).toBe(false)
|
||||
expect(mockToastError).toHaveBeenCalledWith('nodes.humanInputV2.template.testSaveFailed')
|
||||
})
|
||||
|
||||
it('keeps the input screen available after a failed preview and allows retry', async () => {
|
||||
mockFetchHumanInputNodeStepRunForm.mockRejectedValueOnce(new Error('Preview unavailable'))
|
||||
const { result } = renderHook(() =>
|
||||
useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
}),
|
||||
)
|
||||
await act(async () => {
|
||||
await result.current.handleShowGeneratedForm({})
|
||||
})
|
||||
expect(result.current.showGeneratedForm).toBe(false)
|
||||
expect(result.current.isGeneratingForm).toBe(false)
|
||||
const subject = renderSubject(createPayload())
|
||||
await act(async () => subject.result.current.form.handleShowGeneratedForm({}))
|
||||
expect(subject.result.current.form.showGeneratedForm).toBe(false)
|
||||
expect(subject.result.current.form.isGeneratingForm).toBe(false)
|
||||
expect(mockToastError).toHaveBeenCalledWith('Preview unavailable')
|
||||
await act(async () => {
|
||||
await result.current.handleShowGeneratedForm({})
|
||||
})
|
||||
expect(result.current.showGeneratedForm).toBe(true)
|
||||
await act(async () => subject.result.current.form.handleShowGeneratedForm({}))
|
||||
expect(subject.result.current.form.formData?.form_content).toBe('Rendered content')
|
||||
})
|
||||
|
||||
it('normalizes nullable server defaults and rejects malformed response fields', async () => {
|
||||
@@ -219,42 +192,29 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
...mockFormData,
|
||||
inputs: [{ type: 'paragraph', output_variable_name: 'answer', default: null }],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
}),
|
||||
)
|
||||
await act(async () => {
|
||||
await result.current.handleShowGeneratedForm({})
|
||||
})
|
||||
expect(result.current.formData?.inputs).toEqual([
|
||||
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: [] },
|
||||
},
|
||||
])
|
||||
act(() => {
|
||||
result.current.handleHideGeneratedForm()
|
||||
})
|
||||
act(() => subject.result.current.form.handleHideGeneratedForm())
|
||||
mockFetchHumanInputNodeStepRunForm.mockResolvedValueOnce({
|
||||
...mockFormData,
|
||||
inputs: [{ type: 'unknown' }],
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleShowGeneratedForm({})
|
||||
})
|
||||
expect(result.current.showGeneratedForm).toBe(false)
|
||||
expect(result.current.isGeneratingForm).toBe(false)
|
||||
await act(async () => subject.result.current.form.handleShowGeneratedForm({}))
|
||||
expect(subject.result.current.form.showGeneratedForm).toBe(false)
|
||||
expect(subject.result.current.form.isGeneratingForm).toBe(false)
|
||||
})
|
||||
|
||||
it.each([undefined, '2'] as const)(
|
||||
'automatically generates the V%s form when the child mounts in StrictMode',
|
||||
async (version) => {
|
||||
currentInputs = createPayload({ version })
|
||||
const payload = createPayload({ version, _isSingleRun: false })
|
||||
const AutoGenerate = ({ form }: { form: ReturnType<typeof useSingleRunFormParams> }) => {
|
||||
const hasRunRef = useRef(false)
|
||||
const { handleShowGeneratedForm } = form
|
||||
@@ -266,34 +226,43 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
return createElement(
|
||||
'div',
|
||||
null,
|
||||
form.showGeneratedForm ? form.formData?.form_content : 'pending',
|
||||
form.showGeneratedForm ? form.formData?.form_content : 'Generating form',
|
||||
)
|
||||
}
|
||||
const Panel = ({ open }: { open: boolean }) => {
|
||||
const form = useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: { ...currentInputs, _isSingleRun: open },
|
||||
payload: { ...payload, _isSingleRun: open },
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
setRunInputData: () => undefined,
|
||||
})
|
||||
return open ? createElement(AutoGenerate, { form }) : null
|
||||
}
|
||||
const { rerender } = render(
|
||||
createElement(StrictMode, null, createElement(Panel, { open: false })),
|
||||
)
|
||||
rerender(createElement(StrictMode, null, createElement(Panel, { open: true })))
|
||||
const options = {
|
||||
hooksStoreProps: {
|
||||
doSyncWorkflowDraft: vi.fn(async () => ({ hash: 'saved', updatedAt: 1 })),
|
||||
},
|
||||
nodes: [createNode({ id: 'node-1', data: payload })],
|
||||
resource: { kind: 'app', appId: 'app-1', mode: 'workflow' } as const,
|
||||
}
|
||||
const renderPanel = (open: boolean) =>
|
||||
createElement(StrictMode, null, createElement(Panel, { open }))
|
||||
const subject = renderWorkflowFlowComponent(renderPanel(false), options)
|
||||
|
||||
subject.rerender(renderPanel(true))
|
||||
expect(await screen.findByText('Rendered content')).toBeInTheDocument()
|
||||
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledOnce()
|
||||
rerender(createElement(StrictMode, null, createElement(Panel, { open: false })))
|
||||
rerender(createElement(StrictMode, null, createElement(Panel, { open: true })))
|
||||
subject.rerender(renderPanel(false))
|
||||
await waitFor(() => expect(screen.queryByText('Rendered content')).not.toBeInTheDocument())
|
||||
subject.rerender(renderPanel(true))
|
||||
expect(await screen.findByText('Rendered content')).toBeInTheDocument()
|
||||
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledTimes(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('ignores an old form response after the single-run session closes and reopens', async () => {
|
||||
currentInputs = createPayload({ _isSingleRun: true })
|
||||
const payload = createPayload({ _isSingleRun: true })
|
||||
let resolveOld!: (value: HumanInputFormData) => void
|
||||
mockFetchHumanInputNodeStepRunForm.mockImplementationOnce(
|
||||
() =>
|
||||
@@ -301,41 +270,29 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
resolveOld = resolve
|
||||
}),
|
||||
)
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
}),
|
||||
)
|
||||
const subject = renderSubject(payload)
|
||||
let oldRequest!: Promise<void>
|
||||
act(() => {
|
||||
oldRequest = result.current.handleShowGeneratedForm({ topic: 'old' })
|
||||
})
|
||||
currentInputs = createPayload({ _isSingleRun: false })
|
||||
rerender()
|
||||
currentInputs = createPayload({ _isSingleRun: true })
|
||||
rerender()
|
||||
await act(async () => {
|
||||
await result.current.handleShowGeneratedForm({ topic: 'new' })
|
||||
oldRequest = subject.result.current.form.handleShowGeneratedForm({ topic: 'old' })
|
||||
})
|
||||
setSingleRunOpen(subject, payload, false)
|
||||
setSingleRunOpen(subject, payload, true)
|
||||
await act(async () => subject.result.current.form.handleShowGeneratedForm({ topic: 'new' }))
|
||||
await act(async () => {
|
||||
resolveOld({ ...mockFormData, form_content: 'Old response' })
|
||||
await oldRequest
|
||||
})
|
||||
expect(result.current.formData?.form_content).toBe('Rendered content')
|
||||
await act(async () => {
|
||||
await result.current.handleSubmitHumanInputForm({
|
||||
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(mockSubmitHumanInputNodeStepRunForm).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ inputs: { topic: 'new' } }),
|
||||
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
|
||||
{ inputs: { topic: 'new' }, form_inputs: {}, action: 'approve' },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -351,9 +308,7 @@ vi.mock('@/service/console', async (importOriginal) => {
|
||||
body: unknown
|
||||
}) =>
|
||||
mockFetchHumanInputNodeStepRunForm(
|
||||
`/apps/${input.params.app_id}/workflows/draft/human-input/nodes/${
|
||||
input.params.node_id
|
||||
}/form`,
|
||||
`/apps/${input.params.app_id}/workflows/draft/human-input/nodes/${input.params.node_id}/form`,
|
||||
input.body,
|
||||
),
|
||||
'apps.byAppId.workflows.draft.humanInput.nodes.byNodeId.form.run.post': (input: {
|
||||
@@ -361,9 +316,7 @@ vi.mock('@/service/console', async (importOriginal) => {
|
||||
body: unknown
|
||||
}) =>
|
||||
mockSubmitHumanInputNodeStepRunForm(
|
||||
`/apps/${input.params.app_id}/workflows/draft/human-input/nodes/${
|
||||
input.params.node_id
|
||||
}/form`,
|
||||
`/apps/${input.params.app_id}/workflows/draft/human-input/nodes/${input.params.node_id}/form`,
|
||||
input.body,
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { KnowledgeFsSettingsResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { Source, SourceWorkflowRun } from '../../source-models'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { StrictMode } from 'react'
|
||||
import { createHttpError } from '@/service/http-error'
|
||||
import { render } from '@/test/console/render'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { WebsiteCrawlPreview } from '../website-crawl-preview'
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
asyncImport: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
createSource: vi.fn(),
|
||||
deleteSource: vi.fn(),
|
||||
@@ -15,8 +17,26 @@ const clientMock = vi.hoisted(() => ({
|
||||
getSource: vi.fn(),
|
||||
listSources: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
settings: vi.fn(),
|
||||
startPreview: vi.fn(),
|
||||
}))
|
||||
const settingsResponse = vi.hoisted((): KnowledgeFsSettingsResponse => ({
|
||||
active_profile_available: true,
|
||||
active_profile_revisions: { embedding: 1, retrieval: 1 },
|
||||
capabilities: {
|
||||
deep: true,
|
||||
index: true,
|
||||
ingest: true,
|
||||
query: true,
|
||||
research: true,
|
||||
source_sync: true,
|
||||
},
|
||||
configuration_state: 'active',
|
||||
embedding: null,
|
||||
issues: [],
|
||||
retrieval: null,
|
||||
revision: 1,
|
||||
}))
|
||||
const sourceApiResponse = vi.hoisted(() => (source: Source) => ({
|
||||
connection_id: source.connectionId ?? null,
|
||||
created_at: source.createdAt,
|
||||
@@ -77,93 +97,49 @@ const crawlPreviewPageListApiResponse = vi.hoisted(
|
||||
|
||||
const routerMock = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({ useRouter: () => routerMock }))
|
||||
|
||||
vi.mock('../crawl-selection-form', () => ({
|
||||
CrawlSelectionForm: ({
|
||||
busy,
|
||||
initialSelectedPageIds,
|
||||
onCancel,
|
||||
onInteractionLockChange,
|
||||
onRecrawl,
|
||||
pages,
|
||||
}: {
|
||||
busy?: boolean
|
||||
initialSelectedPageIds?: readonly string[]
|
||||
onCancel: () => void
|
||||
onInteractionLockChange?: (locked: boolean) => void
|
||||
onRecrawl: () => void
|
||||
pages: Array<{ pageId: string; title?: string }>
|
||||
}) => (
|
||||
<div>
|
||||
<p role="status">knowledgeSources.pagesCrawled</p>
|
||||
{pages.map((page) => (
|
||||
<label key={page.pageId}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={page.title}
|
||||
checked={initialSelectedPageIds?.includes(page.pageId) ?? false}
|
||||
readOnly
|
||||
/>
|
||||
{page.title}
|
||||
</label>
|
||||
))}
|
||||
<button type="button" aria-disabled={busy} disabled={busy} onClick={onRecrawl}>
|
||||
knowledgeSources.reCrawl
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
knowledgeSources.cancelAddSource
|
||||
</button>
|
||||
<button type="button" onClick={() => onInteractionLockChange?.(true)}>
|
||||
knowledgeSpace.addSource
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/datasets/new/space-1/sources/new/website',
|
||||
useRouter: () => routerMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleClient: {
|
||||
knowledgeFs: {
|
||||
spaces: {
|
||||
byControlSpaceId: {
|
||||
sourceWorkflows: {
|
||||
byRunId: {
|
||||
cancel: {
|
||||
post: async (input: unknown) => workflowApiResponse(await clientMock.cancel(input)),
|
||||
},
|
||||
get: async (input: unknown) => workflowApiResponse(await clientMock.getRun(input)),
|
||||
pages: {
|
||||
get: async (input: unknown) =>
|
||||
crawlPreviewPageListApiResponse(await clientMock.getPages(input)),
|
||||
},
|
||||
retry: {
|
||||
post: async (input: unknown) => workflowApiResponse(await clientMock.retry(input)),
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: {
|
||||
bySourceId: {
|
||||
delete: async (input: unknown) => clientMock.deleteSource(input),
|
||||
get: async (input: unknown) => sourceApiResponse(await clientMock.getSource(input)),
|
||||
crawlPreview: {
|
||||
post: async (input: unknown) =>
|
||||
workflowApiResponse(await clientMock.startPreview(input)),
|
||||
},
|
||||
},
|
||||
get: async (input: unknown) => {
|
||||
const response = await clientMock.listSources(input)
|
||||
return {
|
||||
data: response.items.map(sourceApiResponse),
|
||||
next_cursor: response.nextCursor ?? null,
|
||||
}
|
||||
},
|
||||
post: async (input: unknown) => sourceApiResponse(await clientMock.createSource(input)),
|
||||
},
|
||||
},
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleClientResponses, withConsoleResponses } =
|
||||
await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: withConsoleClientResponses(actual.consoleClient, {
|
||||
'knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.cancel.post': async (input) =>
|
||||
workflowApiResponse(await clientMock.cancel(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.get': async (input) =>
|
||||
workflowApiResponse(await clientMock.getRun(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.pages.get': async (input) =>
|
||||
crawlPreviewPageListApiResponse(await clientMock.getPages(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post': async (input) =>
|
||||
workflowApiResponse(await clientMock.retry(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.asyncImport.post': async (input) =>
|
||||
workflowApiResponse(await clientMock.asyncImport(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.crawlPreview.post': async (input) =>
|
||||
workflowApiResponse(await clientMock.startPreview(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.delete': (input) =>
|
||||
clientMock.deleteSource(input),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.get': async (input) =>
|
||||
sourceApiResponse(await clientMock.getSource(input)),
|
||||
'knowledgeFs.spaces.byControlSpaceId.sources.get': async (input) => {
|
||||
const response = await clientMock.listSources(input)
|
||||
return {
|
||||
data: response.items.map(sourceApiResponse),
|
||||
next_cursor: response.nextCursor ?? null,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
'knowledgeFs.spaces.byControlSpaceId.sources.post': async (input) =>
|
||||
sourceApiResponse(await clientMock.createSource(input)),
|
||||
}),
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'knowledgeFs.spaces.byControlSpaceId.settings.get': clientMock.settings,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
@@ -233,9 +209,11 @@ describe('WebsiteCrawlPreview', () => {
|
||||
vi.useRealTimers()
|
||||
for (const mock of Object.values(clientMock)) mock.mockReset()
|
||||
clientMock.cancel.mockResolvedValue(run('canceled'))
|
||||
clientMock.asyncImport.mockResolvedValue(run('queued', { checkpoint: 'import' }))
|
||||
clientMock.createSource.mockResolvedValue(source())
|
||||
clientMock.deleteSource.mockResolvedValue({})
|
||||
clientMock.getSource.mockResolvedValue(source())
|
||||
clientMock.settings.mockResolvedValue(settingsResponse)
|
||||
clientMock.startPreview.mockResolvedValue(run('running'))
|
||||
clientMock.getRun.mockResolvedValue(
|
||||
run('preview_ready', {
|
||||
@@ -473,11 +451,12 @@ describe('WebsiteCrawlPreview', () => {
|
||||
await waitFor(() => expect(onInteractionLockChange).toHaveBeenLastCalledWith(true))
|
||||
const crawling = screen.getByRole('button', { name: 'knowledgeSpace.crawling' })
|
||||
expect(crawling).toBeDisabled()
|
||||
expect(crawling.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the surrounding setup locked while the selected pages are submitted', async () => {
|
||||
const onInteractionLockChange = vi.fn()
|
||||
const request = deferred<SourceWorkflowRun>()
|
||||
clientMock.asyncImport.mockReturnValue(request.promise)
|
||||
render(
|
||||
<WebsiteCrawlPreview
|
||||
connection={connection}
|
||||
@@ -489,9 +468,36 @@ describe('WebsiteCrawlPreview', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'knowledgeSpace.crawlAndPreview' }))
|
||||
await screen.findByText('Getting started')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'knowledgeSpace.addSource' }))
|
||||
const addSource = screen.getByRole('button', { name: 'knowledgeSpace.addSource' })
|
||||
const gettingStarted = screen.getByRole('checkbox', { name: 'Getting started' })
|
||||
await user.click(gettingStarted)
|
||||
expect(gettingStarted).toBeChecked()
|
||||
await waitFor(() => expect(addSource).toBeEnabled())
|
||||
await user.click(addSource)
|
||||
|
||||
await waitFor(() => expect(clientMock.asyncImport).toHaveBeenCalledOnce())
|
||||
expect(clientMock.asyncImport).toHaveBeenCalledWith({
|
||||
body: {
|
||||
kind: 'crawl-preview-selection',
|
||||
pageIds: ['page-1'],
|
||||
previewWorkflowId: 'run-1',
|
||||
syncPolicy: { enabled: true, mode: 'interval' },
|
||||
},
|
||||
headers: { 'Idempotency-Key': expect.any(String) },
|
||||
params: { control_space_id: 'space-1', source_id: 'source-1' },
|
||||
})
|
||||
await waitFor(() => expect(onInteractionLockChange).toHaveBeenLastCalledWith(true))
|
||||
expect(screen.getByLabelText(/^knowledgeSources\.rootUrl/)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^knowledgeSources\.sourceName/)).toBeDisabled()
|
||||
expect(screen.getByRole('checkbox', { name: 'Getting started' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
|
||||
request.resolve(run('queued', { checkpoint: 'import' }))
|
||||
await waitFor(() =>
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources'),
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels a preview-ready workflow and starts a fresh run when re-crawling', async () => {
|
||||
@@ -823,10 +829,6 @@ describe('WebsiteCrawlPreview', () => {
|
||||
).toHaveValue(3)
|
||||
const status = screen.getByText(/^knowledgeSpace\.crawlingPages/)
|
||||
expect(status).toHaveAttribute('role', 'status')
|
||||
expect(status.querySelector('button')).not.toBeInTheDocument()
|
||||
const skeletons = screen.getAllByTestId('crawl-page-skeleton')
|
||||
expect(skeletons).toHaveLength(4)
|
||||
expect(skeletons[0]?.closest('ul')).toBe(screen.getByRole('list'))
|
||||
await act(async () => vi.advanceTimersByTime(1500))
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument()
|
||||
expect(screen.getByText('New first')).toBeInTheDocument()
|
||||
@@ -841,11 +843,14 @@ describe('WebsiteCrawlPreview', () => {
|
||||
params: { control_space_id: 'space-1', run_id: 'run-1' },
|
||||
query: { cursor: 'final-page-2', limit: 200 },
|
||||
})
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('checkbox', { name: /^(New first|Two|Updated one|Three)$/ })
|
||||
.map((checkbox) => checkbox.getAttribute('aria-label')),
|
||||
).toEqual(['New first', 'Two', 'Updated one', 'Three'])
|
||||
const finalPages = screen.getAllByRole('checkbox', {
|
||||
name: /^(New first|Two|Updated one|Three)$/,
|
||||
})
|
||||
expect(finalPages).toHaveLength(4)
|
||||
expect(finalPages[0]).toHaveAccessibleName('New first')
|
||||
expect(finalPages[1]).toHaveAccessibleName('Two')
|
||||
expect(finalPages[2]).toHaveAccessibleName('Updated one')
|
||||
expect(finalPages[3]).toHaveAccessibleName('Three')
|
||||
expect(screen.getByRole('checkbox', { name: 'Two' })).toBeChecked()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'knowledgeSpace.stopCrawl' }),
|
||||
|
||||
Reference in new issue
Block a user