mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): exercise real shared chat and generation owners (#44661)
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user