test(web): exercise real shared chat and generation owners (#44661)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-25 03:22:56 +00:00
1 parent 21a2081267
commit 241f89c57c
2 files changed
+482 -588

No files matched your search

@@ -1,477 +1,420 @@
import type { HumanInputFieldValue } from '../../chat/answer/human-input-content/field-renderer'
import type { ChatConfig, ChatItem, ChatItemInTree } from '../../types'
import type { ChatItemInTree } from '../../types'
import type { EmbeddedChatbotContextValue } from '../context'
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { AppSourceType, fetchSuggestedQuestions, submitHumanInputForm } from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { createDeferred } from '@/test/deferred'
import { useChat } from '../../chat/hooks'
import { onTestFinished } from 'vite-plus/test'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
// oxlint-disable-next-line no-restricted-imports -- Exercise the existing chat SSE transport at its network boundary.
import { sseGet, ssePost } from '@/service/base'
import {
AppSourceType,
fetchSuggestedQuestions,
stopChatMessageResponding,
submitHumanInputForm,
} from '@/service/share'
import { submitHumanInputForm as submitInstalledHumanInput } from '@/service/workflow'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat'
import { createEmbeddedChatbotContextFixture } from '@/test/fixtures/embedded-chatbot'
import {
createHumanInputFormFixture,
createWorkflowProcessFixture,
} from '@/test/fixtures/workflow-process'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import ChatWrapper from '../chat-wrapper'
import { EmbeddedChatbotContext } from '../context'
const mockTrackEvent = vi.hoisted(() => vi.fn())
// oxlint-disable-next-line typescript/no-deprecated -- Existing SSE transport boundary; no new API is introduced.
const post = vi.mocked(ssePost)
// oxlint-disable-next-line typescript/no-deprecated -- Existing workflow event transport boundary.
const get = vi.mocked(sseGet)
const ContextProvider = EmbeddedChatbotContext.Provider
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({
trackWebAppEvent: mockTrackEvent,
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: 'share-code' }),
usePathname: () => '/chat/share-code',
}))
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({ trackWebAppEvent: vi.fn() }))
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
ssePost: vi.fn(),
sseGet: vi.fn(),
}))
vi.mock('@/service/share', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/share')>()),
fetchSuggestedQuestions: vi.fn().mockResolvedValue({ data: ['Tell me more'] }),
stopChatMessageResponding: vi.fn().mockResolvedValue(undefined),
submitHumanInputForm: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/service/workflow', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/workflow')>()),
submitHumanInputForm: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../chat/hooks', () => ({
useChat: vi.fn(),
}))
vi.mock('../inputs-form', () => ({
__esModule: true,
default: () => <div>inputs form</div>,
}))
vi.mock('../../chat', () => ({
__esModule: true,
default: ({
chatNode,
chatList,
inputDisabled,
questionIcon,
answerIcon,
onSend,
onRegenerate,
switchSibling,
onHumanInputFormSubmit,
onStopResponding,
}: {
chatNode: React.ReactNode
chatList: ChatItem[]
inputDisabled: boolean
questionIcon?: React.ReactNode
answerIcon?: React.ReactNode
onSend: (message: string) => void
onRegenerate: (
chatItem: ChatItem,
editedQuestion?: { message: string; files?: never[] },
) => void
switchSibling: (siblingMessageId: string) => void
onHumanInputFormSubmit: (
formToken: string,
formData: { inputs: Record<string, HumanInputFieldValue>; action: string },
) => Promise<void>
onStopResponding: () => void
}) => (
<div>
<div>{chatNode}</div>
{answerIcon}
{chatList.map((item) => (
<div key={item.id}>{item.content}</div>
))}
<div>chat count: {chatList.length}</div>
{questionIcon}
<button onClick={() => onSend('hello world')}>send through chat</button>
<button
onClick={() =>
onRegenerate({
id: 'answer-1',
isAnswer: true,
content: 'answer',
parentMessageId: 'question-1',
})
}
>
regenerate answer
</button>
<button
onClick={() =>
onRegenerate(
{
id: 'question-1',
isAnswer: false,
content: 'question',
parentMessageId: 'prior-answer',
},
{ message: 'new query' },
)
}
>
regenerate edited
</button>
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
<button disabled={inputDisabled}>send message</button>
<button onClick={onStopResponding}>stop responding</button>
<button
onClick={() =>
onHumanInputFormSubmit('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
}
>
submit human input
</button>
</div>
const answer = (
id: string,
content: string,
overrides: Partial<ChatItemInTree> = {},
): ChatItemInTree => ({
id,
content,
isAnswer: true,
children: [],
...overrides,
})
const question = (id: string, content: string, child: ChatItemInTree): ChatItemInTree => ({
id,
content,
isAnswer: false,
children: [{ ...child, parentMessageId: id }],
})
const history = (): ChatItemInTree[] => [
question(
'prior-question',
'Previous question',
answer('prior-answer', 'Previous answer', {
children: [
{
...question('question-1', 'Original question', answer('answer-1', 'Original answer')),
parentMessageId: 'prior-answer',
},
],
}),
),
}))
]
const pausedAnswer = (id = 'paused-answer', runId = 'run-1'): ChatItemInTree =>
answer(id, '', {
workflow_run_id: runId,
workflowProcess: createWorkflowProcessFixture({ status: WorkflowRunningStatus.Paused }),
humanInputFormDataList: [
createHumanInputFormFixture({
form_id: `form-${id}`,
node_id: 'human-input',
node_title: 'Approval',
actions: [{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }],
form_token: 'form-token',
}),
],
})
vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/share')>()
return {
...actual,
fetchSuggestedQuestions: vi.fn(),
getUrl: vi.fn(() => '/chat-messages'),
stopChatMessageResponding: vi.fn(),
submitHumanInputForm: vi.fn(),
}
})
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(),
}))
const mockIsDify = vi.fn(() => false)
vi.mock('../utils', () => ({
isDify: () => mockIsDify(),
}))
type UseChatReturn = ReturnType<typeof useChat>
const createContextValue = (
overrides: Partial<EmbeddedChatbotContextValue> = {},
): EmbeddedChatbotContextValue => ({
appMeta: { tool_icons: {} },
appData: {
app_id: 'app-1',
mode: 'chat',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
function renderSubject(overrides: Partial<EmbeddedChatbotContextValue> = {}) {
const context = createEmbeddedChatbotContextFixture({
appId: 'app-1',
appData: {
app_id: 'app-1',
mode: 'chat',
custom_config: null,
site: {
title: 'Embedded App',
icon_type: 'emoji',
icon: '🤖',
input_placeholder: 'Ask the assistant',
},
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
icon_type: 'emoji',
icon: 'bot',
icon_background: '#000000',
icon_url: '',
use_icon_as_answer_icon: false,
},
},
appParams: {
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
more_like_this: {
enabled: false,
},
} as ChatConfig,
appChatListDataLoading: false,
currentConversationId: '',
currentConversationItem: undefined,
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: vi.fn(),
inputsForms: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
chatShouldReloadKey: 'reload-key',
isMobile: false,
isInstalledApp: false,
appSourceType: AppSourceType.webApp,
allowResetChat: true,
appId: 'app-1',
disableFeedback: false,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
theme: undefined,
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
setIsResponding: vi.fn(),
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
initUserVariables: {},
...overrides,
})
const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatReturn => ({
chatList: [],
setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'],
handleSend: vi.fn(),
handleResume: vi.fn(),
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
handleStop: vi.fn(),
handleSwitchSibling: vi.fn(),
prepareHumanInputSubmission: vi.fn().mockResolvedValue(true),
isResponding: false,
suggestedQuestions: [],
handleRestart: vi.fn(),
handleAnnotationEdited: vi.fn(),
handleAnnotationAdded: vi.fn(),
handleAnnotationRemoved: vi.fn(),
...overrides,
})
function renderSubject(
contextOverrides: Partial<EmbeddedChatbotContextValue> = {},
chatOverrides: Partial<UseChatReturn> = {},
) {
const context = createContextValue(contextOverrides)
const chat = createUseChatReturn(chatOverrides)
vi.mocked(useChat).mockReturnValue(chat)
const result = render(
<EmbeddedChatbotContext.Provider value={context}>
appMeta: { tool_icons: {} },
appParams: createChatConfigFixture({ questionEditEnable: true }),
...overrides,
})
const view = renderWithConsoleQuery(
<ContextProvider value={context}>
<ChatWrapper />
</EmbeddedChatbotContext.Provider>,
</ContextProvider>,
)
return {
...result,
...view,
context,
chat,
user: userEvent.setup(),
updateContext: (changes: Partial<EmbeddedChatbotContextValue>) =>
result.rerender(
<EmbeddedChatbotContext.Provider value={{ ...context, ...changes }}>
view.rerender(
<ContextProvider value={{ ...context, ...changes }}>
<ChatWrapper />
</EmbeddedChatbotContext.Provider>,
</ContextProvider>,
),
}
}
beforeEach(() => {
vi.clearAllMocks()
mockIsDify.mockReturnValue(false)
})
async function completeReply(content = 'Streamed answer', index = 0) {
const callbacks = post.mock.calls[index]![2]
await act(async () => {
callbacks.onData?.(content, true, {
conversationId: 'created-conversation',
messageId: `response-${index}`,
taskId: `task-${index}`,
})
await callbacks.onCompleted?.()
})
}
function confirmPaused() {
const callbacks = get.mock.calls[0]![2]
act(() =>
callbacks.onWorkflowPaused?.({
task_id: 'task-1',
workflow_run_id: 'run-1',
event: 'workflow_paused',
data: { workflow_run_id: 'run-1', outputs: {}, paused_nodes: ['human-input'], reasons: [] },
}),
)
}
describe('Embedded chatbot orchestration', () => {
beforeEach(() => {
const stopGuard = guardUnexpectedFetch()
onTestFinished(stopGuard)
})
it.each([
[InputVarType.textInput, '', true],
[InputVarType.textInput, 'Ada', false],
[InputVarType.number, 0, false],
[InputVarType.checkbox, false, false],
[InputVarType.multiFiles, [], true],
[InputVarType.multiFiles, [{ transferMethod: 'local_file' }], true],
[InputVarType.multiFiles, [{ transferMethod: 'local_file', uploadedId: 'upload-1' }], false],
[InputVarType.singleFile, { transferMethod: 'local_file' }, true],
[InputVarType.singleFile, { transferMethod: 'local_file', uploadedId: 'upload-1' }, false],
[
InputVarType.multiFiles,
[createChatFileFixture({ uploadedId: undefined, progress: 0 })],
true,
],
[InputVarType.multiFiles, [createChatFileFixture()], false],
[InputVarType.singleFile, createChatFileFixture({ uploadedId: undefined, progress: 0 }), true],
[InputVarType.singleFile, createChatFileFixture(), false],
])('enforces required %s input readiness for %j', (type, value, disabled) => {
renderSubject({
inputsForms: [{ variable: 'value', label: 'Value', required: true, type }],
newConversationInputs: { value },
newConversationInputsRef: { current: { value } },
})
const send = screen.getByRole('button', { name: 'send message' })
if (disabled) expect(send).toBeDisabled()
else expect(send).toBeEnabled()
const input = screen.getByPlaceholderText('Ask the assistant')
if (disabled) expect(input).toBeDisabled()
else expect(input).toBeEnabled()
})
it('uses existing conversation inputs rather than new-conversation defaults', async () => {
const { user, chat } = renderSubject(
it('uses saved conversation inputs and the last answer when sending', async () => {
const { user } = renderSubject({
currentConversationId: 'conversation-1',
currentConversationInputs: { topic: 'saved' },
newConversationInputs: { topic: 'new' },
inputsForms: [
{ variable: 'topic', label: 'Topic', required: true, type: InputVarType.textInput },
],
appPrevChatList: [question('last-question', 'Question', answer('last-answer', 'Answer'))],
})
await user.type(screen.getByPlaceholderText('Ask the assistant'), 'hello world{Enter}')
expect(post).toHaveBeenCalledExactlyOnceWith(
'chat-messages',
{
currentConversationId: 'conversation-1',
currentConversationInputs: { topic: 'saved' },
newConversationInputs: { topic: 'new' },
newConversationInputsRef: { current: {} },
inputsForms: [
{ variable: 'topic', label: 'Topic', required: true, type: InputVarType.textInput },
],
},
{ chatList: [{ id: 'last-answer', isAnswer: true, content: 'Answer' }] },
)
expect(screen.getByRole('button', { name: 'send message' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'send through chat' }))
expect(chat.handleSend).toHaveBeenCalledExactlyOnceWith(
'/chat-messages',
{
query: 'hello world',
files: undefined,
inputs: { topic: 'saved' },
conversation_id: 'conversation-1',
parent_message_id: 'last-answer',
},
{
onGetSuggestedQuestions: expect.any(Function),
onConversationComplete: undefined,
isPublicAPI: true,
},
)
expect(mockTrackEvent).toHaveBeenCalledExactlyOnceWith('webapp_run', { app_mode: 'chat' })
})
it('wires completion and suggested questions for a new conversation', async () => {
const { user, chat, context } = renderSubject({ newConversationInputs: { topic: 'new' } })
await user.click(screen.getByRole('button', { name: 'send through chat' }))
expect(chat.handleSend).toHaveBeenCalledWith(
'/chat-messages',
{
query: 'hello world',
files: undefined,
inputs: { topic: 'new' },
conversation_id: '',
parent_message_id: null,
body: {
query: 'hello world',
files: [],
inputs: { topic: 'saved' },
conversation_id: 'conversation-1',
parent_message_id: 'last-answer',
response_mode: 'streaming',
},
},
expect.objectContaining({ isPublicAPI: true }),
)
const options = vi.mocked(chat.handleSend).mock.calls[0]![2]!
options.onConversationComplete?.('created-conversation')
options.onGetSuggestedQuestions?.('response-1', vi.fn())
expect(context.handleNewConversationCompleted).toHaveBeenCalledExactlyOnceWith(
'created-conversation',
await completeReply()
expect(await screen.findByText('Streamed answer')).toBeInTheDocument()
})
it('completes a new conversation and displays suggested questions', async () => {
const completed = vi.fn()
const { user } = renderSubject({
newConversationInputs: { topic: 'new' },
newConversationInputsRef: { current: { topic: 'new' } },
inputsForms: [
{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput },
],
appParams: createChatConfigFixture({ suggested_questions_after_answer: { enabled: true } }),
handleNewConversationCompleted: completed,
})
await user.type(screen.getByPlaceholderText('Ask the assistant'), 'hello world{Enter}')
expect(post).toHaveBeenCalledWith(
'chat-messages',
{
body: {
query: 'hello world',
files: [],
inputs: { topic: 'new' },
conversation_id: '',
parent_message_id: null,
response_mode: 'streaming',
},
},
expect.objectContaining({ isPublicAPI: true }),
)
await completeReply()
expect(completed).toHaveBeenCalledExactlyOnceWith('created-conversation', undefined)
expect(fetchSuggestedQuestions).toHaveBeenCalledExactlyOnceWith(
'response-1',
'response-0',
AppSourceType.webApp,
'app-1',
)
expect(await screen.findByRole('button', { name: 'Tell me more' })).toBeInTheDocument()
})
it.each([true, false])(
'waits for workflow readiness before submitting human input (installed=%s)',
'waits for workflow readiness before human input submission (installed=%s)',
async (installed) => {
const ready = createDeferred<boolean>()
const { user, chat } = renderSubject(
{ isInstalledApp: installed },
{ prepareHumanInputSubmission: vi.fn(() => ready.promise) },
)
await user.click(screen.getByRole('button', { name: 'submit human input' }))
expect(chat.prepareHumanInputSubmission).toHaveBeenCalledTimes(1)
expect(submitHumanInputFormService).not.toHaveBeenCalled()
expect(submitHumanInputForm).not.toHaveBeenCalled()
await act(async () => ready.resolve(true))
const service = installed ? submitHumanInputFormService : submitHumanInputForm
const otherService = installed ? submitHumanInputForm : submitHumanInputFormService
expect(service).toHaveBeenCalledExactlyOnceWith('form-token', {
inputs: { answer: 'ok' },
action: 'approve',
const { user } = renderSubject({
isInstalledApp: installed,
currentConversationId: 'conversation-1',
appPrevChatList: [question('paused-question', 'Question', pausedAnswer())],
})
expect(otherService).not.toHaveBeenCalled()
await user.click(await screen.findByRole('button', { name: 'Approve' }))
expect(submitHumanInputForm).not.toHaveBeenCalled()
expect(submitInstalledHumanInput).not.toHaveBeenCalled()
confirmPaused()
await waitFor(() =>
expect(
installed ? submitInstalledHumanInput : submitHumanInputForm,
).toHaveBeenCalledExactlyOnceWith('form-token', { inputs: {}, action: 'approve' }),
)
expect(installed ? submitHumanInputForm : submitInstalledHumanInput).not.toHaveBeenCalled()
},
)
it('does not submit human input when the workflow cannot resume', async () => {
const { user } = renderSubject(
{},
{ prepareHumanInputSubmission: vi.fn().mockResolvedValue(false) },
)
await user.click(screen.getByRole('button', { name: 'submit human input' }))
it('does not submit human input when the workflow subscription fails', async () => {
const { user } = renderSubject({
currentConversationId: 'conversation-1',
appPrevChatList: [question('paused-question', 'Question', pausedAnswer())],
})
await user.click(await screen.findByRole('button', { name: 'Approve' }))
await act(async () => get.mock.calls[0]![2].onCompleted?.(true, 'Disconnected'))
expect(submitHumanInputForm).not.toHaveBeenCalled()
expect(submitHumanInputFormService).not.toHaveBeenCalled()
expect(submitInstalledHumanInput).not.toHaveBeenCalled()
})
it('regenerates the original question or its edit against the preceding answer', async () => {
const chatList = [
{
id: 'prior-answer',
isAnswer: true,
content: 'Previous answer',
metadata: { usage: { total_tokens: 10 } },
},
{
id: 'question-1',
isAnswer: false,
content: 'Original question',
parentMessageId: 'prior-answer',
},
{ id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' },
] as ChatItem[]
const { user, chat } = renderSubject({}, { chatList })
await user.click(screen.getByRole('button', { name: 'regenerate answer' }))
await user.click(screen.getByRole('button', { name: 'regenerate edited' }))
expect(vi.mocked(chat.handleSend).mock.calls.map((call) => call[1])).toEqual([
{
query: 'Original question',
files: undefined,
inputs: {},
conversation_id: '',
parent_message_id: 'prior-answer',
},
{
query: 'new query',
files: undefined,
inputs: {},
conversation_id: '',
parent_message_id: 'prior-answer',
},
])
})
it('switches siblings and stops through the active chat instance', async () => {
const { user, chat, context } = renderSubject({
appSourceType: AppSourceType.tryApp,
it('regenerates a question and its edit against the preceding answer', async () => {
const { user } = renderSubject({
appPrevChatList: history(),
currentConversationId: 'conversation-1',
})
await user.click(screen.getByRole('button', { name: 'switch sibling' }))
expect(chat.handleSwitchSibling).toHaveBeenCalledExactlyOnceWith('sibling-2', {
onGetSuggestedQuestions: expect.any(Function),
onConversationComplete: undefined,
isPublicAPI: false,
})
await user.click(screen.getByRole('button', { name: 'stop responding' }))
expect(chat.handleStop).toHaveBeenCalledTimes(1)
context.currentChatInstanceRef.current.handleStop()
expect(chat.handleStop).toHaveBeenCalledTimes(2)
expect(mockTrackEvent).not.toHaveBeenCalled()
await user.click(screen.getAllByRole('button', { name: 'common.operation.regenerate' }).at(-1)!)
await completeReply('Regenerated answer')
await user.click(screen.getAllByRole('button', { name: 'common.operation.edit' }).at(-1)!)
const editor = screen.getByDisplayValue('Original question')
await user.clear(editor)
await user.type(editor, 'new query{Enter}')
expect(post.mock.calls.map((call) => call[1].body)).toEqual([
expect.objectContaining({ query: 'Original question', parent_message_id: 'prior-answer' }),
expect.objectContaining({ query: 'new query', parent_message_id: 'prior-answer' }),
])
await completeReply('Edited answer', 1)
expect(await screen.findByText('Edited answer')).toBeInTheDocument()
})
it('resumes the deepest paused workflow after history arrives, only once per conversation', async () => {
const { updateContext, chat } = renderSubject()
const pausedChild = {
id: 'child',
isAnswer: true,
content: '',
workflow_run_id: 'run-1',
humanInputFormDataList: [{ label: 'Need info' }],
children: [],
}
const history = [
{ ...pausedChild, id: 'parent', children: [pausedChild] },
] as unknown as ChatItemInTree[]
expect(chat.handleSwitchSibling).not.toHaveBeenCalled()
updateContext({ appPrevChatList: history, currentConversationId: 'conversation-1' })
it('switches visible siblings and stops through the active chat instance', async () => {
const siblings: ChatItemInTree[] = [
{
id: 'question-1',
content: 'Question',
isAnswer: false,
children: [
answer('sibling-1', 'First answer', { parentMessageId: 'question-1', siblingIndex: 0 }),
],
},
{
id: 'question-2',
content: 'Question',
isAnswer: false,
children: [
answer('sibling-2', 'Second answer', {
parentMessageId: 'question-2',
siblingIndex: 1,
}),
],
},
]
const { user, context } = renderSubject({
appSourceType: AppSourceType.tryApp,
currentConversationId: 'conversation-1',
appPrevChatList: siblings,
})
await user.click(screen.getByRole('button', { name: 'Previous' }))
expect(await screen.findByText('First answer')).toBeInTheDocument()
expect(screen.queryByText('Second answer')).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText('Ask the assistant'), 'follow up{Enter}')
act(() =>
post.mock.calls[0]![2].onData?.('In progress', true, {
messageId: 'response-0',
taskId: 'task-0',
}),
)
await user.click(screen.getByRole('button', { name: /stopResponding/ }))
expect(stopChatMessageResponding).toHaveBeenCalledWith(
'',
'task-0',
AppSourceType.tryApp,
'app-1',
)
expect(screen.queryByRole('button', { name: /stopResponding/ })).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText('Ask the assistant'), 'another request{Enter}')
act(() =>
post.mock.calls[1]![2].onData?.('Another response', true, {
messageId: 'response-1',
taskId: 'task-1',
}),
)
await act(async () => context.currentChatInstanceRef.current.handleStop())
expect(stopChatMessageResponding).toHaveBeenLastCalledWith(
'',
'task-1',
AppSourceType.tryApp,
'app-1',
)
expect(screen.queryByRole('button', { name: /stopResponding/ })).not.toBeInTheDocument()
})
it('resumes the deepest paused workflow once for the loaded conversation', async () => {
const parent = pausedAnswer('parent', 'parent-run')
parent.children = [
{
...question('child-question', 'Follow-up', pausedAnswer('child', 'child-run')),
parentMessageId: 'parent',
},
]
const conversation = [question('parent-question', 'Question', parent)]
const { updateContext } = renderSubject({
appPrevChatList: conversation,
currentConversationId: 'conversation-1',
})
await waitFor(() =>
expect(chat.handleSwitchSibling).toHaveBeenCalledExactlyOnceWith(
'child',
expect(get).toHaveBeenCalledExactlyOnceWith(
'/workflow/child-run/events?include_state_snapshot=true&continue_on_pause=true',
{},
expect.objectContaining({ isPublicAPI: true }),
),
)
updateContext({ appPrevChatList: [...history], currentConversationId: 'conversation-1' })
expect(chat.handleSwitchSibling).toHaveBeenCalledTimes(1)
updateContext({ appPrevChatList: conversation, currentConversationId: 'conversation-1' })
expect(get).toHaveBeenCalledTimes(1)
})
it('shows the welcome text when input collection is hidden and hides it while responding', async () => {
const { updateContext } = renderSubject(
{ inputsForms: [{ variable: 'name', required: true }], allInputsHidden: true },
{
chatList: [
{
id: 'opening',
isAnswer: true,
isOpeningStatement: true,
content: 'Welcome to the app',
},
],
},
)
expect(await screen.findByText('Welcome to the app')).toBeInTheDocument()
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
vi.mocked(useChat).mockReturnValue(
createUseChatReturn({
isResponding: true,
chatList: [
{
id: 'opening',
isAnswer: true,
isOpeningStatement: true,
content: 'Welcome to the app',
},
],
it('shows welcome when inputs hidden and hides it while responding', async () => {
const { user } = renderSubject({
newConversationInputs: { name: 'Ada' },
newConversationInputsRef: { current: { name: 'Ada' } },
inputsForms: [
{ variable: 'name', label: 'Name', required: true, type: InputVarType.textInput },
],
allInputsHidden: true,
appParams: createChatConfigFixture({ opening_statement: 'Welcome to the app' }),
})
const welcome = await screen.findByText('Welcome to the app')
expect(welcome).toBeVisible()
expect(screen.queryByRole('textbox', { name: 'Name' })).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText('Ask the assistant'), 'hello world{Enter}')
act(() =>
post.mock.calls[0]![2].onData?.('In progress', true, {
messageId: 'response-0',
taskId: 'task-0',
}),
)
updateContext({})
expect(screen.queryByText('Welcome to the app')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /stopResponding/ })).toBeVisible()
// The opening statement remains in chat, but the original welcome presentation is removed.
expect(welcome).not.toBeInTheDocument()
expect(screen.getByText('Welcome to the app')).toBeVisible()
})
})
@@ -1,214 +1,165 @@
import type { TextGenerationRunControl } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { onTestFinished } from 'vite-plus/test'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { AppSourceType, sendCompletionMessage } from '@/service/share'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createChatConfigFixture } from '@/test/fixtures/chat'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import TextGeneration from '../index'
const {
mockMode,
mockMedia,
mockAppStateRef,
mockBatchStateRef,
sidebarPropsSpy,
resultPanelPropsSpy,
mockSetIsCallBatchAPI,
mockResetBatchExecution,
mockHandleRunBatch,
} = vi.hoisted(() => ({
mockMode: { value: 'create' },
mockMedia: { value: 'pc' },
mockAppStateRef: { value: null as unknown },
mockBatchStateRef: { value: null as unknown },
sidebarPropsSpy: vi.fn(),
resultPanelPropsSpy: vi.fn(),
mockSetIsCallBatchAPI: vi.fn(),
mockResetBatchExecution: vi.fn(),
mockHandleRunBatch: vi.fn(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
MediaType: {
mobile: 'mobile',
pc: 'pc',
tablet: 'tablet',
},
default: () => mockMedia.value,
const { searchParams, acceptCSV } = vi.hoisted(() => ({
searchParams: new URLSearchParams(),
acceptCSV: { current: (_payload: { data: string[][] }) => {} },
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => (key === 'mode' ? mockMode.value : null),
useSearchParams: () => searchParams,
usePathname: () => '/completion/share-code',
useParams: () => ({ token: 'share-code' }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/i18n/client', () => ({ changeLanguage: vi.fn().mockResolvedValue(undefined) }))
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({ trackWebAppEvent: vi.fn() }))
vi.mock('@/service/share', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/share')>()),
fetchSavedMessage: vi.fn().mockResolvedValue({ data: [] }),
sendCompletionMessage: vi.fn().mockResolvedValue(undefined),
}))
// CSV parsing is an external SDK boundary; the upload wrapper, batch owner and results stay real.
vi.mock('react-papaparse', () => ({
useCSVReader: () => ({
CSVReader: ({
onUploadAccepted,
children,
}: {
onUploadAccepted: typeof acceptCSV.current
children: (context: { getRootProps: () => object; acceptedFile: null }) => ReactNode
}) => {
acceptCSV.current = onUploadAccepted
return children({ getRootProps: () => ({}), acceptedFile: null })
},
}),
useCSVDownloader: () => ({
CSVDownloader: ({ children }: { children: ReactNode }) => <>{children}</>,
Type: { Link: 'link' },
}),
}))
vi.mock('../hooks/use-text-generation-app-state', () => ({
useTextGenerationAppState: () => mockAppStateRef.value,
}))
function renderGeneration({ ready = true, installed = false } = {}) {
if (ready) {
useWebAppStore.setState({
appInfo: {
app_id: 'app-1',
mode: 'completion',
custom_config: null,
site: { title: 'Generator', description: 'Description', default_language: 'en-US' },
},
appParams: createChatConfigFixture({
user_input_form: [
{
'text-input': {
label: 'Name',
variable: 'name',
required: true,
max_length: 48,
default: '',
hide: false,
},
},
],
}),
webAppAccessMode: AccessMode.PUBLIC,
})
}
return renderWithConsoleQuery(<TextGeneration isInstalledApp={installed} />, {
systemFeatures: { branding: { enabled: false, workspace_logo: '' } },
})
}
vi.mock('../hooks/use-text-generation-batch', () => ({
useTextGenerationBatch: () => mockBatchStateRef.value,
}))
vi.mock('../text-generation-sidebar', () => ({
default: (props: {
currentTab: string
onRunOnceSend: () => void
onBatchSend: (data: string[][]) => void
}) => {
sidebarPropsSpy(props)
return (
<div data-testid="sidebar">
<span data-testid="sidebar-current-tab">{props.currentTab}</span>
<button type="button" onClick={props.onRunOnceSend}>
run-once
</button>
<button type="button" onClick={() => props.onBatchSend([['name'], ['Alice']])}>
run-batch
</button>
</div>
)
},
}))
vi.mock('../text-generation-result-panel', () => ({
default: (props: {
allTaskList: unknown[]
controlSend: number
controlStopResponding: number
isShowResultPanel: boolean
onRunControlChange: (value: TextGenerationRunControl | null) => void
onRunStart: () => void
}) => {
resultPanelPropsSpy(props)
return (
<div data-testid="result-panel">
<span data-testid="show-result">{props.isShowResultPanel ? 'shown' : 'hidden'}</span>
<span data-testid="control-send">{String(props.controlSend)}</span>
<span data-testid="control-stop">{String(props.controlStopResponding)}</span>
<span data-testid="task-count">{String(props.allTaskList.length)}</span>
<button
type="button"
onClick={() => props.onRunControlChange({ isStopping: false, onStop: vi.fn() })}
>
set-run-control
</button>
<button type="button" onClick={props.onRunStart}>
start-run
</button>
</div>
)
},
}))
const createAppState = (overrides: Record<string, unknown> = {}) => ({
accessMode: AccessMode.PUBLIC,
appId: 'app-1',
appSourceType: 'webApp',
customConfig: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
handleRemoveSavedMessage: vi.fn(),
handleSaveMessage: vi.fn(),
moreLikeThisConfig: { enabled: true },
promptConfig: {
prompt_template: '',
prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }],
},
savedMessages: [],
siteInfo: {
title: 'Generator',
description: 'Description',
},
systemFeatures: {},
textToSpeechConfig: { enabled: true },
visionConfig: { enabled: false },
...overrides,
})
const createBatchState = (overrides: Record<string, unknown> = {}) => ({
allFailedTaskList: [],
allSuccessTaskList: [],
allTaskList: [],
allTasksRun: true,
controlRetry: 0,
exportRes: [],
handleCompleted: vi.fn(),
handleRetryAllFailedTask: vi.fn(),
handleRunBatch: (data: string[][], options: { onStart: () => void }) => {
mockHandleRunBatch(data, options)
options.onStart()
return true
},
isCallBatchAPI: false,
noPendingTask: true,
resetBatchExecution: () => mockResetBatchExecution(),
setIsCallBatchAPI: (value: boolean) => mockSetIsCallBatchAPI(value),
showTaskList: [],
...overrides,
beforeEach(() => {
searchParams.delete('mode')
})
describe('TextGeneration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMode.value = 'create'
mockMedia.value = 'pc'
mockAppStateRef.value = createAppState()
mockBatchStateRef.value = createBatchState()
useWebAppStore.setState(useWebAppStore.getInitialState(), true)
const stopGuard = guardUnexpectedFetch()
onTestFinished(stopGuard)
})
it('should render the loading state until app state is ready', () => {
mockAppStateRef.value = createAppState({ appId: '', siteInfo: null, promptConfig: null })
render(<TextGeneration />)
it('shows loading until app metadata is ready', () => {
renderGeneration({ ready: false })
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
})
it('should fall back to create mode for unsupported query params', () => {
mockMode.value = 'unsupported'
render(<TextGeneration isInstalledApp />)
expect(screen.getByTestId('sidebar-current-tab')).toHaveTextContent('create')
expect(sidebarPropsSpy).toHaveBeenCalledWith(
expect.objectContaining({
currentTab: 'create',
isInstalledApp: true,
isPC: true,
}),
it('falls back to the create tab for an unsupported mode in an installed app', async () => {
searchParams.set('mode', 'unsupported')
renderGeneration({ installed: true })
expect(
await screen.findByRole('tab', { name: 'share.generation.tabs.create' }),
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'share.generation.tabs.batch' })).toHaveAttribute(
'aria-selected',
'false',
)
})
it('should orchestrate a run-once request and reveal the result panel', async () => {
it('runs the entered inputs and displays the streamed completion', async () => {
const user = userEvent.setup()
render(<TextGeneration />)
await user.click(screen.getByRole('button', { name: 'run-once' }))
expect(mockSetIsCallBatchAPI).toHaveBeenCalledWith(false)
expect(mockResetBatchExecution).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(screen.getByTestId('show-result')).toHaveTextContent('shown')
renderGeneration()
await user.type(await screen.findByRole('textbox', { name: 'Name' }), 'Alice')
await user.click(screen.getByRole('button', { name: 'share.generation.run' }))
await waitFor(() =>
expect(sendCompletionMessage).toHaveBeenCalledWith(
{ inputs: { name: 'Alice' } },
expect.any(Object),
AppSourceType.webApp,
'app-1',
),
)
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument()
const handlers = vi.mocked(sendCompletionMessage).mock.calls[0]![1]
act(() => {
handlers.onData?.('Hello Alice', false, { messageId: 'message-1', taskId: 'task-1' })
handlers.onCompleted?.()
})
expect(Number(screen.getByTestId('control-send').textContent)).toBeGreaterThan(0)
expect(await screen.findByText('Hello Alice')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
it('should orchestrate batch runs through the batch hook and expose the result panel', async () => {
it('runs accepted CSV rows through the batch owner and renders their results', async () => {
const user = userEvent.setup()
mockMode.value = 'batch'
render(<TextGeneration />)
await user.click(screen.getByRole('button', { name: 'run-batch' }))
expect(mockHandleRunBatch).toHaveBeenCalledWith(
[['name'], ['Alice']],
expect.objectContaining({ onStart: expect.any(Function) }),
searchParams.set('mode', 'batch')
renderGeneration()
const batch = await screen.findByRole('tabpanel', { name: 'share.generation.tabs.batch' })
act(() => acceptCSV.current({ data: [['Name'], ['Alice']] }))
await user.click(within(batch).getByRole('button', { name: 'share.generation.run' }))
await waitFor(() =>
expect(sendCompletionMessage).toHaveBeenCalledWith(
{ inputs: { name: 'Alice' } },
expect.any(Object),
AppSourceType.webApp,
'app-1',
),
)
await waitFor(() => {
expect(screen.getByTestId('show-result')).toHaveTextContent('shown')
expect(screen.getByText('share.generation.executions:{"num":1}')).toBeInTheDocument()
const handlers = vi.mocked(sendCompletionMessage).mock.calls[0]![1]
act(() => {
handlers.onData?.('Batch result for Alice', false, {
messageId: 'message-1',
taskId: 'task-1',
})
handlers.onCompleted?.()
})
expect(Number(screen.getByTestId('control-stop').textContent)).toBeGreaterThan(0)
expect(await screen.findByText('Batch result for Alice')).toBeInTheDocument()
expect(within(batch).getByRole('button', { name: 'share.generation.run' })).toBeEnabled()
})
})