test(web): replace AP1/AP3/AP11/AP30/AP32 test doubles (#45197)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-31 09:39:05 +00:00
1 parent 8ba1adfbc4
commit dcb332693e
4 files changed
+566 -683

No files matched your search

@@ -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', () => {
@@ -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' }),