mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
test(web): migrate agent log modal specs to browser mode (#45677)
This commit is contained in:
1 parent
ebf662d434
commit
141bf922cf
12 files changed
+644
-735
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
|
||||
export async function renderAgentLog(ui: ReactElement) {
|
||||
const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper()
|
||||
const screen = await render(<QueryProvider>{ui}</QueryProvider>)
|
||||
return {
|
||||
queryClient,
|
||||
screen,
|
||||
rerender: (next: ReactElement) => screen.rerender(<QueryProvider>{next}</QueryProvider>),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { AgentLogResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { createHttpError } from '@/service/http-error'
|
||||
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
|
||||
import AgentLogDetail from '../detail'
|
||||
import { renderAgentLog } from './browser-test-env'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({ error: vi.fn() }))
|
||||
const fetchAgentLogDetail = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@monaco-editor/react', async () => {
|
||||
const { createMonacoEditorModuleMock } = await import('@/test/monaco-editor-mock')
|
||||
return createMonacoEditorModuleMock()
|
||||
})
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/console')>()),
|
||||
consoleClient: { apps: { byAppId: { agent: { logs: { get: fetchAgentLogDetail } } } } },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/notifications', () => ({ toast: toastMock }))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: (timestamp: number, format: string) => `${timestamp}-${format}` }),
|
||||
}))
|
||||
|
||||
const log: IChatItem = {
|
||||
id: 'msg-id',
|
||||
content: 'output content',
|
||||
isAnswer: false,
|
||||
conversationId: 'conv-id',
|
||||
input: 'user input',
|
||||
}
|
||||
|
||||
const createResponse = (overrides: Partial<AgentLogResponse> = {}): AgentLogResponse => ({
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor: 'User',
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1,
|
||||
total_tokens: 100,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 1,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: 'Final answer',
|
||||
tokens: 7,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
async function renderDetail(props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) {
|
||||
return renderAgentLog(
|
||||
<AgentLogDetail
|
||||
appId="app-id"
|
||||
conversationID="conv-id"
|
||||
messageID="msg-id"
|
||||
log={log}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onTestFinished(guardUnexpectedFetch())
|
||||
fetchAgentLogDetail.mockReset()
|
||||
})
|
||||
|
||||
it('shows loading until the requested record resolves', async () => {
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
const { screen } = await renderDetail()
|
||||
await expect.element(screen.getByRole('progressbar')).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders the requested inputs, outputs and execution metadata in real readonly editors', async () => {
|
||||
fetchAgentLogDetail.mockResolvedValue(createResponse())
|
||||
const { screen } = await renderDetail()
|
||||
await expect.element(screen.getByText('User')).toBeVisible()
|
||||
const editors = screen.getByTestId('monaco-editor').all()
|
||||
expect(editors).toHaveLength(2)
|
||||
await expect.element(editors[0]!).toHaveValue(JSON.stringify('user input'))
|
||||
await expect.element(editors[1]!).toHaveValue(JSON.stringify('output content'))
|
||||
for (const editor of editors) await expect.element(editor).toHaveAttribute('readonly')
|
||||
await expect.element(screen.getByRole('status')).toMatchTextContent('100 Tokens')
|
||||
await expect.element(screen.getByText('tool1')).toBeVisible()
|
||||
})
|
||||
|
||||
it('switches between detail and tracing with native keyboard focus without refetching', async () => {
|
||||
fetchAgentLogDetail.mockResolvedValue(createResponse())
|
||||
const { screen } = await renderDetail()
|
||||
const detail = screen.getByRole('button', { name: 'runLog.detail' })
|
||||
const tracing = screen.getByRole('button', { name: 'runLog.tracing' })
|
||||
await expect.element(screen.getByText('User')).toBeVisible()
|
||||
await detail.click()
|
||||
await userEvent.keyboard('{Tab}')
|
||||
await expect.element(tracing).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(screen.getByText('appLog.agentLogDetail.finalProcessing')).toBeVisible()
|
||||
await userEvent.keyboard('{Shift>}{Tab}{/Shift}')
|
||||
await expect.element(detail).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(screen.getByText('User')).toBeVisible()
|
||||
expect(fetchAgentLogDetail).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows a failed execution consistently in its summary and metadata', async () => {
|
||||
const failed = createResponse()
|
||||
failed.meta.status = 'failed'
|
||||
fetchAgentLogDetail.mockResolvedValue(failed)
|
||||
const { screen } = await renderDetail()
|
||||
await expect.element(screen.getByText('appLog.status.failed')).toBeVisible()
|
||||
await expect.element(screen.getByText('FAILED')).toBeVisible()
|
||||
await expect.element(screen.getByText('appLog.status.succeeded')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('removes loading and reports the service error', async () => {
|
||||
fetchAgentLogDetail.mockRejectedValue(
|
||||
createHttpError(new Response(null, { status: 500 }), { message: 'API Error' }),
|
||||
)
|
||||
const { screen } = await renderDetail()
|
||||
await expect.poll(() => toastMock.error).toHaveBeenCalledWith('API Error')
|
||||
await expect.element(screen.getByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('summarizes empty and repeated trace data from the service response', async () => {
|
||||
fetchAgentLogDetail.mockResolvedValueOnce(createResponse({ iterations: [] }))
|
||||
const { rerender, screen } = await renderDetail()
|
||||
await expect.element(screen.getByText('Null')).toBeVisible()
|
||||
await expect.element(screen.getByText('0', { exact: true })).toBeVisible()
|
||||
|
||||
const repeated = createResponse()
|
||||
repeated.iterations = [
|
||||
repeated.iterations[0]!,
|
||||
{
|
||||
...repeated.iterations[0]!,
|
||||
tool_calls: [
|
||||
repeated.iterations[0]!.tool_calls[0]!,
|
||||
{ ...repeated.iterations[0]!.tool_calls[0]!, tool_name: 'tool2' },
|
||||
],
|
||||
},
|
||||
]
|
||||
fetchAgentLogDetail.mockResolvedValueOnce(repeated)
|
||||
await rerender(
|
||||
<AgentLogDetail
|
||||
appId="app-id"
|
||||
conversationID="conv-id"
|
||||
messageID="next-message"
|
||||
log={{ ...log, id: 'next-message' }}
|
||||
/>,
|
||||
)
|
||||
await expect.element(screen.getByText('tool1, tool2')).toBeVisible()
|
||||
await expect.element(screen.getByText('2', { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -1,269 +0,0 @@
|
||||
import type { AgentLogResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createHttpError } from '@/service/http-error'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import AgentLogDetail from '../detail'
|
||||
|
||||
const { mockToast } = vi.hoisted(() => {
|
||||
const mockToast = Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
})
|
||||
return { mockToast }
|
||||
})
|
||||
|
||||
const { fetchAgentLogDetail } = vi.hoisted(() => ({
|
||||
fetchAgentLogDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/console')>()),
|
||||
consoleClient: { apps: { byAppId: { agent: { logs: { get: fetchAgentLogDetail } } } } },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ title, value }: { title: ReactNode; value: string | object }) => (
|
||||
<div data-testid="code-editor">
|
||||
{title}
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }),
|
||||
}))
|
||||
|
||||
const createMockLog = (overrides: Partial<IChatItem> = {}): IChatItem => ({
|
||||
id: 'msg-id',
|
||||
content: 'output content',
|
||||
isAnswer: false,
|
||||
conversationId: 'conv-id',
|
||||
input: 'user input',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockResponse = (overrides: Partial<AgentLogResponse> = {}): AgentLogResponse => ({
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor: 'User',
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1.0,
|
||||
total_tokens: 100,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 1,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AgentLogDetail', () => {
|
||||
const renderComponent = (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
|
||||
const defaultProps: ComponentProps<typeof AgentLogDetail> = {
|
||||
appId: 'app-id',
|
||||
conversationID: 'conv-id',
|
||||
messageID: 'msg-id',
|
||||
log: createMockLog(),
|
||||
}
|
||||
return render(<AgentLogDetail {...defaultProps} {...props} />)
|
||||
}
|
||||
|
||||
const renderAndWaitForData = async (
|
||||
props: Partial<ComponentProps<typeof AgentLogDetail>> = {},
|
||||
) => {
|
||||
const result = renderComponent(props)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetchAgentLogDetail.mockReset()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading indicator while fetching data', async () => {
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call fetchAgentLogDetail with correct params', async () => {
|
||||
fetchAgentLogDetail.mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
expect(fetchAgentLogDetail).toHaveBeenCalledWith(
|
||||
{
|
||||
params: { app_id: 'app-id' },
|
||||
query: { conversation_id: 'conv-id', message_id: 'msg-id' },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show TRACING tab when activeTab is TRACING', async () => {
|
||||
fetchAgentLogDetail.mockResolvedValue(createMockResponse())
|
||||
|
||||
await renderAndWaitForData({ activeTab: 'TRACING' })
|
||||
|
||||
expect(screen.getByText('appLog.agentLogDetail.finalProcessing')).toBeInTheDocument()
|
||||
expect(screen.queryByText('user input')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('switches between the request/result and tool trace', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchAgentLogDetail.mockResolvedValue(createMockResponse())
|
||||
await renderAndWaitForData()
|
||||
expect(screen.getByText('user input')).toBeInTheDocument()
|
||||
expect(screen.getByText('output content')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'runLog.tracing' }))
|
||||
expect(screen.getByText('appLog.agentLogDetail.finalProcessing')).toBeInTheDocument()
|
||||
expect(screen.queryByText('user input')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'runLog.detail' }))
|
||||
expect(screen.getByText('user input')).toBeInTheDocument()
|
||||
expect(screen.queryByText('appLog.agentLogDetail.finalProcessing')).not.toBeInTheDocument()
|
||||
expect(fetchAgentLogDetail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows failed execution as failed in both summary and metadata', async () => {
|
||||
const response = createMockResponse()
|
||||
response.meta.status = 'failed'
|
||||
fetchAgentLogDetail.mockResolvedValue(response)
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
expect(screen.getByText('appLog.status.failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('FAILED')).toBeInTheDocument()
|
||||
expect(screen.queryByText('appLog.status.succeeded')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('SUCCESS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should notify on API error', async () => {
|
||||
fetchAgentLogDetail.mockRejectedValue(
|
||||
createHttpError(new Response(null, { status: 500 }), { message: 'API Error' }),
|
||||
)
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith('API Error')
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle response with empty iterations', async () => {
|
||||
fetchAgentLogDetail.mockResolvedValue(createMockResponse({ iterations: [] }))
|
||||
|
||||
await renderAndWaitForData()
|
||||
expect(screen.getByText('Null')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle response with multiple iterations and duplicate tools', async () => {
|
||||
const response = createMockResponse({
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
},
|
||||
{
|
||||
tool_name: 'tool2',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
tool_label: { 'en-US': 'Tool 2' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
fetchAgentLogDetail.mockResolvedValue(response)
|
||||
|
||||
await renderAndWaitForData()
|
||||
|
||||
expect(screen.getByText('tool1, tool2')).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { AgentLogResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { useState } from 'react'
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
|
||||
import AgentLogModal from '../index'
|
||||
import { renderAgentLog } from './browser-test-env'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({ error: vi.fn() }))
|
||||
const fetchAgentLogDetail = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@monaco-editor/react', async () => {
|
||||
const { createMonacoEditorModuleMock } = await import('@/test/monaco-editor-mock')
|
||||
return createMonacoEditorModuleMock()
|
||||
})
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: withConsoleClientResponses(actual.consoleClient, {
|
||||
'apps.byAppId.agent.logs.get': fetchAgentLogDetail,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/notifications', () => ({ toast: toastMock }))
|
||||
|
||||
const log: IChatItem = {
|
||||
id: 'msg-id',
|
||||
conversationId: 'conv-id',
|
||||
content: 'content',
|
||||
isAnswer: false,
|
||||
input: 'test input',
|
||||
}
|
||||
|
||||
const response = (executor: string): AgentLogResponse => ({
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor,
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1,
|
||||
total_tokens: 100,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 1,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: 'Final response',
|
||||
tokens: 1,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [],
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
})
|
||||
|
||||
function ControlledModal({ floating = false }: { floating?: boolean }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<>
|
||||
<button type="button">Outside agent log</button>
|
||||
{open ? (
|
||||
<AgentLogModal
|
||||
appId="app-id"
|
||||
currentLogItem={log}
|
||||
floating={floating}
|
||||
onCancel={() => setOpen(false)}
|
||||
width={1000}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
Reopen agent log
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onTestFinished(guardUnexpectedFetch())
|
||||
fetchAgentLogDetail.mockReset()
|
||||
fetchAgentLogDetail.mockResolvedValue(response('User'))
|
||||
})
|
||||
|
||||
it('closes and reopens the portalled dialog through its real controls', async () => {
|
||||
await page.viewport(1200, 800)
|
||||
const { screen } = await renderAgentLog(<ControlledModal floating />)
|
||||
const dialog = screen.getByRole('dialog', { name: 'appLog.runDetail.workflowTitle' })
|
||||
await expect.element(dialog).toBeVisible()
|
||||
|
||||
await screen.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await screen.getByRole('button', { name: 'Reopen agent log' }).click()
|
||||
await expect.element(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
it('closes the inline viewer only for an outside interaction', async () => {
|
||||
await page.viewport(1200, 800)
|
||||
const { screen } = await renderAgentLog(<ControlledModal />)
|
||||
const title = screen.getByRole('heading', { name: 'appLog.runDetail.workflowTitle' })
|
||||
await title.click()
|
||||
await expect.element(title).toBeVisible()
|
||||
|
||||
await screen.getByRole('button', { name: 'Outside agent log' }).click()
|
||||
await expect.element(title).not.toBeInTheDocument()
|
||||
await screen.getByRole('button', { name: 'Reopen agent log' }).click()
|
||||
await expect.element(title).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps the new record visible when the previous response arrives late', async () => {
|
||||
const oldResponse = createDeferred<AgentLogResponse>()
|
||||
fetchAgentLogDetail.mockImplementationOnce(() => oldResponse.promise)
|
||||
fetchAgentLogDetail.mockResolvedValueOnce(response('New executor'))
|
||||
const { rerender, screen } = await renderAgentLog(
|
||||
<AgentLogModal appId="old-app" currentLogItem={log} onCancel={() => {}} width={1000} />,
|
||||
)
|
||||
|
||||
await rerender(
|
||||
<AgentLogModal appId="new-app" currentLogItem={log} onCancel={() => {}} width={1000} />,
|
||||
)
|
||||
await expect.element(screen.getByText('New executor')).toBeVisible()
|
||||
oldResponse.resolve(response('Old executor'))
|
||||
await expect.element(screen.getByText('New executor')).toBeVisible()
|
||||
await expect.element(screen.getByText('Old executor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the selected tracing view when another message replaces the record', async () => {
|
||||
await page.viewport(1200, 800)
|
||||
const { rerender, screen } = await renderAgentLog(
|
||||
<AgentLogModal appId="app-id" currentLogItem={log} onCancel={() => {}} width={1000} />,
|
||||
)
|
||||
await expect.element(screen.getByText('User')).toBeVisible()
|
||||
const tracing = screen.getByRole('button', { name: 'runLog.tracing' })
|
||||
await tracing.click()
|
||||
await expect.element(screen.getByText('appLog.agentLogDetail.finalProcessing')).toBeVisible()
|
||||
|
||||
await rerender(
|
||||
<AgentLogModal
|
||||
appId="app-id"
|
||||
currentLogItem={{ ...log, id: 'next-message' }}
|
||||
onCancel={() => {}}
|
||||
width={1000}
|
||||
/>,
|
||||
)
|
||||
await expect.element(screen.getByText('appLog.agentLogDetail.finalProcessing')).toBeVisible()
|
||||
await expect.element(tracing).toHaveFocus()
|
||||
expect(fetchAgentLogDetail).toHaveBeenLastCalledWith(
|
||||
{
|
||||
params: { app_id: 'app-id' },
|
||||
query: { conversation_id: 'conv-id', message_id: 'next-message' },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a failed request after its record has been replaced', async () => {
|
||||
const oldResponse = createDeferred<AgentLogResponse>()
|
||||
fetchAgentLogDetail.mockImplementationOnce(() => oldResponse.promise)
|
||||
const { rerender, screen } = await renderAgentLog(
|
||||
<AgentLogModal appId="old-app" currentLogItem={log} onCancel={() => {}} width={1000} />,
|
||||
)
|
||||
await rerender(
|
||||
<AgentLogModal appId="new-app" currentLogItem={log} onCancel={() => {}} width={1000} />,
|
||||
)
|
||||
await expect.element(screen.getByText('User')).toBeVisible()
|
||||
oldResponse.reject(new Error('Old request failed'))
|
||||
await expect.element(screen.getByText('User')).toBeVisible()
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,229 +0,0 @@
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import AgentLogModal from '../index'
|
||||
|
||||
const { mockToast } = vi.hoisted(() => {
|
||||
const mockToast = Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
})
|
||||
return { mockToast }
|
||||
})
|
||||
|
||||
const { fetchAgentLogDetail } = vi.hoisted(() => ({
|
||||
fetchAgentLogDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: withConsoleClientResponses(actual.consoleClient, {
|
||||
'apps.byAppId.agent.logs.get': fetchAgentLogDetail,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
const mockLog = {
|
||||
id: 'msg-id',
|
||||
conversationId: 'conv-id',
|
||||
content: 'content',
|
||||
isAnswer: false,
|
||||
input: 'test input',
|
||||
} as IChatItem
|
||||
|
||||
const mockProps = {
|
||||
currentLogItem: mockLog,
|
||||
width: 1000,
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
describe('AgentLogModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
fetchAgentLogDetail.mockResolvedValue({
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor: 'User',
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1.0,
|
||||
total_tokens: 100,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 1,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought: '',
|
||||
tokens: 0,
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null if no currentLogItem', () => {
|
||||
const { container } = render(
|
||||
<AgentLogModal appId="app-id" {...mockProps} currentLogItem={undefined} />,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null if no conversationId', () => {
|
||||
const { container } = render(
|
||||
<AgentLogModal
|
||||
appId="app-id"
|
||||
{...mockProps}
|
||||
currentLogItem={{ id: '1' } as unknown as IChatItem}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render the floating modal through a dialog portal', () => {
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { container } = render(<AgentLogModal appId="app-id" {...mockProps} floating />)
|
||||
|
||||
const modal = screen.getByRole('dialog')
|
||||
expect(container).not.toContainElement(modal)
|
||||
expect(document.body).toContainElement(modal)
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
render(<AgentLogModal appId="app-id" {...mockProps} />)
|
||||
|
||||
const closeBtn = screen.getByRole('button', { name: 'common.operation.close' })
|
||||
await user.click(closeBtn)
|
||||
|
||||
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when clicking outside the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
render(<AgentLogModal appId="app-id" {...mockProps} />)
|
||||
await user.click(document.body)
|
||||
expect(mockProps.onCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not close when clicking inside the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
render(<AgentLogModal appId="app-id" {...mockProps} />)
|
||||
await user.click(screen.getByText('appLog.runDetail.workflowTitle'))
|
||||
expect(mockProps.onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the floating dialog open when clicking its content', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
|
||||
render(<AgentLogModal appId="app-id" {...mockProps} floating />)
|
||||
await user.click(screen.getByRole('dialog'))
|
||||
expect(mockProps.onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the new app log visible when the previous app response arrives late', async () => {
|
||||
const response = {
|
||||
meta: {
|
||||
status: 'succeeded',
|
||||
executor: 'New executor',
|
||||
start_time: '2023-01-01',
|
||||
elapsed_time: 1,
|
||||
total_tokens: 1,
|
||||
agent_mode: 'function_call',
|
||||
iterations: 0,
|
||||
},
|
||||
iterations: [],
|
||||
files: [],
|
||||
}
|
||||
let resolveOldResponse!: (value: Awaited<ReturnType<typeof fetchAgentLogDetail>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<typeof fetchAgentLogDetail>>>((resolve) => {
|
||||
resolveOldResponse = resolve
|
||||
})
|
||||
fetchAgentLogDetail.mockResolvedValue(response).mockImplementationOnce(() => pending)
|
||||
const { rerender } = render(<AgentLogModal appId="old-app" {...mockProps} />)
|
||||
rerender(<AgentLogModal appId="new-app" {...mockProps} />)
|
||||
await screen.findByText('New executor')
|
||||
expect(fetchAgentLogDetail).toHaveBeenLastCalledWith(
|
||||
{
|
||||
params: { app_id: 'new-app' },
|
||||
query: { conversation_id: mockLog.conversationId, message_id: mockLog.id },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
await act(async () => {
|
||||
resolveOldResponse({ ...response, meta: { ...response.meta, executor: 'Old executor' } })
|
||||
await pending
|
||||
})
|
||||
expect(screen.getByText('New executor')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Old executor')).not.toBeInTheDocument()
|
||||
})
|
||||
it('preserves the selected tab when another message replaces the open record', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<AgentLogModal appId="app-id" {...mockProps} />)
|
||||
await screen.findByText('User')
|
||||
await user.click(screen.getByRole('button', { name: 'runLog.tracing' }))
|
||||
rerender(
|
||||
<AgentLogModal
|
||||
appId="app-id"
|
||||
{...mockProps}
|
||||
currentLogItem={{ ...mockLog, id: 'next-message' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'runLog.tracing' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument())
|
||||
expect(fetchAgentLogDetail).toHaveBeenLastCalledWith(
|
||||
{
|
||||
params: { app_id: 'app-id' },
|
||||
query: { conversation_id: mockLog.conversationId, message_id: 'next-message' },
|
||||
},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a failed request after its record has been replaced', async () => {
|
||||
let rejectOldResponse: (error: Error) => void = () => {}
|
||||
const pending = new Promise<Awaited<ReturnType<typeof fetchAgentLogDetail>>>((_, reject) => {
|
||||
rejectOldResponse = reject
|
||||
})
|
||||
fetchAgentLogDetail.mockReturnValueOnce(pending)
|
||||
const { rerender } = render(<AgentLogModal appId="old-app" {...mockProps} />)
|
||||
rerender(<AgentLogModal appId="new-app" {...mockProps} />)
|
||||
await screen.findByText('User')
|
||||
await act(async () => {
|
||||
rejectOldResponse(new Error('Old request failed'))
|
||||
})
|
||||
expect(mockToast.error).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('User')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import ResultPanel from '../result'
|
||||
import { renderAgentLog } from './browser-test-env'
|
||||
|
||||
vi.mock('@monaco-editor/react', async () => {
|
||||
const { createMonacoEditorModuleMock } = await import('@/test/monaco-editor-mock')
|
||||
return createMonacoEditorModuleMock()
|
||||
})
|
||||
|
||||
const props = {
|
||||
status: 'success',
|
||||
elapsed_time: 1.23456,
|
||||
total_tokens: 150,
|
||||
error: '',
|
||||
inputs: { query: 'input' },
|
||||
outputs: { answer: 'output' },
|
||||
created_by: 'User Name',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
agentMode: 'function_call',
|
||||
tools: ['tool1', 'tool2'],
|
||||
iterations: 3,
|
||||
}
|
||||
|
||||
it('shows semantic input, output and execution metadata in keyboard-reachable readonly editors', async () => {
|
||||
const { screen } = await renderAgentLog(<ResultPanel {...props} />)
|
||||
const editors = screen.getByTestId('monaco-editor').all()
|
||||
expect(editors).toHaveLength(2)
|
||||
await expect.element(editors[0]!).toHaveValue(JSON.stringify(props.inputs, null, 2))
|
||||
await expect.element(editors[1]!).toHaveValue(JSON.stringify(props.outputs, null, 2))
|
||||
for (const editor of editors) await expect.element(editor).toHaveAttribute('readonly')
|
||||
|
||||
await editors[0]!.click()
|
||||
await expect.element(editors[0]!).toHaveFocus()
|
||||
await userEvent.keyboard('{Tab}')
|
||||
const resizeControls = screen.getByRole('button', { name: 'common.resize.editor' }).all()
|
||||
await expect.element(resizeControls[0]!).toHaveFocus()
|
||||
await userEvent.keyboard('{Tab}')
|
||||
const copyControls = screen.getByRole('button', { name: 'common.operation.copy' }).all()
|
||||
expect(copyControls).toHaveLength(2)
|
||||
for (const copy of copyControls) await expect.element(copy).toBeVisible()
|
||||
expect(copyControls.some((copy) => copy.element() === document.activeElement)).toBe(true)
|
||||
await expect.element(screen.getByText('User Name')).toBeVisible()
|
||||
await expect.element(screen.getByRole('status')).toMatchTextContent('appLog.status.succeeded')
|
||||
await expect.element(screen.getByRole('status')).toMatchTextContent('1.235s')
|
||||
await expect.element(screen.getByRole('status')).toMatchTextContent('150 Tokens')
|
||||
await expect.element(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeVisible()
|
||||
await expect.element(screen.getByText('tool1, tool2')).toBeVisible()
|
||||
await expect.element(screen.getByText('3', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows the available mode and fallbacks when execution metadata is missing', async () => {
|
||||
const { screen } = await renderAgentLog(
|
||||
<ResultPanel
|
||||
{...props}
|
||||
created_by={undefined}
|
||||
tools={[]}
|
||||
total_tokens={undefined}
|
||||
agentMode="react"
|
||||
/>,
|
||||
)
|
||||
await expect.element(screen.getByText('N/A')).toBeVisible()
|
||||
await expect.element(screen.getByText('Null')).toBeVisible()
|
||||
await expect.element(screen.getByRole('status')).toMatchTextContent('0 Tokens')
|
||||
await expect.element(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeVisible()
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import ResultPanel from '../result'
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((ts, _format) => `formatted-${ts}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockProps = {
|
||||
status: 'success',
|
||||
elapsed_time: 1.23456,
|
||||
total_tokens: 150,
|
||||
error: '',
|
||||
inputs: { query: 'input' },
|
||||
outputs: { answer: 'output' },
|
||||
created_by: 'User Name',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
agentMode: 'function_call',
|
||||
tools: ['tool1', 'tool2'],
|
||||
iterations: 3,
|
||||
}
|
||||
|
||||
describe('ResultPanel', () => {
|
||||
it('shows inputs, outputs and execution metadata', () => {
|
||||
render(<ResultPanel {...mockProps} />)
|
||||
|
||||
expect(screen.getByText('INPUT')).toBeInTheDocument()
|
||||
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
|
||||
const editors = screen.getAllByRole('textbox') as HTMLTextAreaElement[]
|
||||
expect(editors.map((editor) => JSON.parse(editor.value))).toEqual([
|
||||
mockProps.inputs,
|
||||
mockProps.outputs,
|
||||
])
|
||||
editors.forEach((editor) => expect(editor).toHaveAttribute('readonly'))
|
||||
expect(screen.getByText('User Name')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('appLog.status.succeeded')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('1.235s')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('150 Tokens')
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool1, tool2')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
|
||||
// Check formatted time
|
||||
expect(screen.getByText(/formatted-/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows available mode and fallbacks when execution metadata is missing', () => {
|
||||
render(
|
||||
<ResultPanel
|
||||
{...mockProps}
|
||||
created_by={undefined}
|
||||
tools={[]}
|
||||
total_tokens={undefined}
|
||||
agentMode="react"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Null')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('0 Tokens')
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useLocale } from '#i18n'
|
||||
import ToolCallItem from '../tool-call'
|
||||
import { renderAgentLog } from './browser-test-env'
|
||||
|
||||
vi.mock('@monaco-editor/react', async () => {
|
||||
const { createMonacoEditorModuleMock } = await import('@/test/monaco-editor-mock')
|
||||
return createMonacoEditorModuleMock()
|
||||
})
|
||||
|
||||
vi.mock('#i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('#i18n')>()),
|
||||
useLocale: vi.fn(() => 'en'),
|
||||
}))
|
||||
|
||||
const toolCall = {
|
||||
status: 'success',
|
||||
error: null,
|
||||
tool_name: 'test_tool',
|
||||
tool_label: { en: 'Test Tool Label' },
|
||||
tool_icon: 'icon',
|
||||
time_cost: 1.5,
|
||||
tool_input: { query: 'hello' },
|
||||
tool_output: { result: 'world' },
|
||||
}
|
||||
|
||||
it('uses the underscored locale fallback and formats the elapsed time', async () => {
|
||||
vi.mocked(useLocale).mockReturnValueOnce('en-US')
|
||||
const { screen } = await renderAgentLog(
|
||||
<ToolCallItem
|
||||
toolCall={{ ...toolCall, tool_label: { en_US: 'Fallback Label' } }}
|
||||
isLLM={false}
|
||||
/>,
|
||||
)
|
||||
await expect.element(screen.getByText('Fallback Label')).toBeVisible()
|
||||
await expect.element(screen.getByText('1.500 s')).toBeVisible()
|
||||
})
|
||||
|
||||
it('formats subsecond and minute-long tool calls', async () => {
|
||||
const { screen } = await renderAgentLog(
|
||||
<>
|
||||
<ToolCallItem toolCall={{ ...toolCall, time_cost: 0.5 }} isLLM={false} />
|
||||
<ToolCallItem toolCall={{ ...toolCall, time_cost: 65 }} isLLM={false} />
|
||||
</>,
|
||||
)
|
||||
await expect.element(screen.getByText('500.000 ms')).toBeVisible()
|
||||
await expect.element(screen.getByText('1 m 5.000 s')).toBeVisible()
|
||||
})
|
||||
|
||||
it('reveals real readonly input and output editors and hides them when collapsed', async () => {
|
||||
const { screen } = await renderAgentLog(<ToolCallItem toolCall={toolCall} isLLM={false} />)
|
||||
const trigger = screen.getByText('Test Tool Label')
|
||||
await expect.element(screen.getByTestId('monaco-editor')).not.toBeInTheDocument()
|
||||
await trigger.click()
|
||||
|
||||
const editors = screen.getByTestId('monaco-editor').all()
|
||||
expect(editors).toHaveLength(2)
|
||||
await expect.element(editors[0]!).toHaveValue(JSON.stringify({ query: 'hello' }, null, 2))
|
||||
await expect.element(editors[1]!).toHaveValue(JSON.stringify({ result: 'world' }, null, 2))
|
||||
for (const editor of editors) await expect.element(editor).toHaveAttribute('readonly')
|
||||
await trigger.click()
|
||||
await expect.element(screen.getByTestId('monaco-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the provider error inside the expanded tool call', async () => {
|
||||
const { screen } = await renderAgentLog(
|
||||
<ToolCallItem
|
||||
toolCall={{ ...toolCall, status: 'error', error: 'Something went wrong' }}
|
||||
isLLM={false}
|
||||
/>,
|
||||
)
|
||||
await screen.getByText('Test Tool Label').click()
|
||||
await expect.element(screen.getByText('Something went wrong')).toBeVisible()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[800, '800'],
|
||||
[1200, '1.2K'],
|
||||
[1200000, '1.2M'],
|
||||
] as const)('formats %s LLM tokens as %s', async (tokens, formatted) => {
|
||||
const { screen } = await renderAgentLog(
|
||||
<ToolCallItem toolCall={toolCall} isLLM tokens={tokens} />,
|
||||
)
|
||||
await expect.element(screen.getByText(`${formatted} tokens`)).toBeVisible()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'reveals the observation and correct result title for isFinal=%s',
|
||||
async (isFinal) => {
|
||||
const { screen } = await renderAgentLog(
|
||||
<ToolCallItem
|
||||
toolCall={toolCall}
|
||||
isLLM
|
||||
observation="Tool returned a result"
|
||||
finalAnswer="Use the result"
|
||||
isFinal={isFinal}
|
||||
/>,
|
||||
)
|
||||
await screen.getByText('LLM').click()
|
||||
const editors = screen.getByTestId('monaco-editor').all()
|
||||
expect(editors).toHaveLength(4)
|
||||
await expect.element(editors[2]!).toHaveValue(JSON.stringify('Tool returned a result'))
|
||||
await expect.element(editors[3]!).toHaveValue(JSON.stringify('Use the result'))
|
||||
await expect.element(screen.getByText(isFinal ? 'FINAL ANSWER' : 'THOUGHT')).toBeVisible()
|
||||
await expect
|
||||
.element(screen.getByText(isFinal ? 'THOUGHT' : 'FINAL ANSWER'))
|
||||
.not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
@@ -1,111 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { useLocale } from '#i18n'
|
||||
import ToolCallItem from '../tool-call'
|
||||
|
||||
vi.mock('#i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('#i18n')>()),
|
||||
useLocale: vi.fn(() => 'en'),
|
||||
}))
|
||||
|
||||
const mockToolCall = {
|
||||
status: 'success',
|
||||
error: null,
|
||||
tool_name: 'test_tool',
|
||||
tool_label: { en: 'Test Tool Label' },
|
||||
tool_icon: 'icon',
|
||||
time_cost: 1.5,
|
||||
tool_input: { query: 'hello' },
|
||||
tool_output: { result: 'world' },
|
||||
}
|
||||
|
||||
describe('ToolCallItem', () => {
|
||||
it('should fallback to locale key with underscores when hyphenated key is missing', () => {
|
||||
vi.mocked(useLocale).mockReturnValueOnce('en-US')
|
||||
const fallbackLocaleToolCall = {
|
||||
...mockToolCall,
|
||||
tool_label: { en_US: 'Fallback Label' },
|
||||
}
|
||||
|
||||
render(<ToolCallItem toolCall={fallbackLocaleToolCall} isLLM={false} />)
|
||||
expect(screen.getByText('Fallback Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format time correctly', () => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
|
||||
expect(screen.getByText('1.500 s')).toBeInTheDocument()
|
||||
|
||||
// Test ms format
|
||||
render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 0.5 }} isLLM={false} />)
|
||||
expect(screen.getByText('500.000 ms')).toBeInTheDocument()
|
||||
|
||||
// Test minute format
|
||||
render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 65 }} isLLM={false} />)
|
||||
expect(screen.getByText('1 m 5.000 s')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals tool inputs and outputs and hides them when collapsed', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Test Tool Label'))
|
||||
const editors = screen.getAllByRole('textbox')
|
||||
expect(screen.getByText('INPUT')).toBeInTheDocument()
|
||||
expect(JSON.parse((editors[0] as HTMLTextAreaElement).value)).toEqual({ query: 'hello' })
|
||||
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
|
||||
expect(JSON.parse((editors[1] as HTMLTextAreaElement).value)).toEqual({ result: 'world' })
|
||||
for (const editor of editors) expect(editor).toHaveAttribute('readonly')
|
||||
await user.click(screen.getByText('Test Tool Label'))
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display error message when status is error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const errorToolCall = {
|
||||
...mockToolCall,
|
||||
status: 'error',
|
||||
error: 'Something went wrong',
|
||||
}
|
||||
render(<ToolCallItem toolCall={errorToolCall} isLLM={false} />)
|
||||
|
||||
await user.click(screen.getByText('Test Tool Label'))
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[800, '800'],
|
||||
[1200, '1.2K'],
|
||||
[1200000, '1.2M'],
|
||||
])('formats %s LLM tokens as %s', (tokens, formatted) => {
|
||||
render(<ToolCallItem toolCall={mockToolCall} isLLM tokens={Number(tokens)} />)
|
||||
expect(screen.getByText(`${formatted} tokens`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'reveals observation and the correct LLM result for isFinal=%s',
|
||||
async (isFinal) => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ToolCallItem
|
||||
toolCall={mockToolCall}
|
||||
isLLM
|
||||
observation="Tool returned a result"
|
||||
finalAnswer="Use the result"
|
||||
isFinal={isFinal}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByText('LLM'))
|
||||
expect(screen.getByText('OBSERVATION')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('textbox')[2]).toHaveValue(
|
||||
JSON.stringify('Tool returned a result'),
|
||||
)
|
||||
expect(screen.getByText(isFinal ? 'FINAL ANSWER' : 'THOUGHT')).toBeInTheDocument()
|
||||
expect(screen.queryByText(isFinal ? 'THOUGHT' : 'FINAL ANSWER')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('textbox')[3]).toHaveValue(JSON.stringify('Use the result'))
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { AgentIterationLogResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import TracingPanel from '../tracing'
|
||||
import { renderAgentLog } from './browser-test-env'
|
||||
|
||||
vi.mock('@monaco-editor/react', async () => {
|
||||
const { createMonacoEditorModuleMock } = await import('@/test/monaco-editor-mock')
|
||||
return createMonacoEditorModuleMock()
|
||||
})
|
||||
|
||||
const createIteration = (thought: string, tokens: number): AgentIterationLogResponse => ({
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought,
|
||||
tokens,
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
},
|
||||
],
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
})
|
||||
|
||||
it('keeps trace output ordered and exposes only the last thought as the final answer', async () => {
|
||||
await page.viewport(800, 500)
|
||||
const { screen } = await renderAgentLog(
|
||||
<TracingPanel
|
||||
list={[
|
||||
createIteration('Thought 1', 10),
|
||||
createIteration('Thought 2', 20),
|
||||
createIteration('Thought 3', 30),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
const iterations = screen.getByText(/ITERATION/i).all()
|
||||
expect(iterations).toHaveLength(2)
|
||||
await expect.element(iterations[0]!).toHaveTextContent('APPLOG.AGENTLOGDETAIL.ITERATION 1')
|
||||
await expect.element(iterations[1]!).toHaveTextContent('APPLOG.AGENTLOGDETAIL.ITERATION 2')
|
||||
await expect.element(screen.getByText(/finalProcessing/i)).toBeVisible()
|
||||
|
||||
for (const llm of screen.getByText('LLM').all()) await llm.click()
|
||||
const editors = screen.getByTestId('monaco-editor').all()
|
||||
expect(editors).toHaveLength(6)
|
||||
const expectedValues = ['', 'Thought 1', '', 'Thought 2', '', 'Thought 3']
|
||||
for (const [index, value] of expectedValues.entries()) {
|
||||
await expect.element(editors[index]!).toHaveValue(JSON.stringify(value))
|
||||
await expect.element(editors[index]!).toHaveAttribute('readonly')
|
||||
}
|
||||
const titles = screen.getByText(/^(?:THOUGHT|FINAL ANSWER)$/).all()
|
||||
expect(titles).toHaveLength(3)
|
||||
await expect.element(titles[0]!).toHaveTextContent('THOUGHT')
|
||||
await expect.element(titles[1]!).toHaveTextContent('THOUGHT')
|
||||
await expect.element(titles[2]!).toHaveTextContent('FINAL ANSWER')
|
||||
await expect.element(titles[2]!).toBeVisible()
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { AgentIterationLogResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import TracingPanel from '../tracing'
|
||||
|
||||
const createIteration = (thought: string, tokens: number): AgentIterationLogResponse => ({
|
||||
created_at: '',
|
||||
files: [],
|
||||
thought,
|
||||
tokens,
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'tool1',
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
tool_label: { 'en-US': 'Tool 1' },
|
||||
time_cost: 0,
|
||||
tool_input: {},
|
||||
tool_output: {},
|
||||
tool_parameters: {},
|
||||
},
|
||||
],
|
||||
tool_raw: { inputs: '', outputs: '' },
|
||||
})
|
||||
|
||||
const mockList: AgentIterationLogResponse[] = [
|
||||
createIteration('Thought 1', 10),
|
||||
createIteration('Thought 2', 20),
|
||||
createIteration('Thought 3', 30),
|
||||
]
|
||||
|
||||
describe('TracingPanel', () => {
|
||||
it('keeps iterations in order and identifies only the last thought as the final answer', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TracingPanel list={mockList} />)
|
||||
|
||||
expect(screen.getAllByText(/ITERATION/i).map((element) => element.textContent)).toEqual([
|
||||
'APPLOG.AGENTLOGDETAIL.ITERATION 1',
|
||||
'APPLOG.AGENTLOGDETAIL.ITERATION 2',
|
||||
])
|
||||
expect(screen.getByText(/finalProcessing/i)).toBeInTheDocument()
|
||||
|
||||
for (const llm of screen.getAllByText('LLM')) await user.click(llm)
|
||||
|
||||
const editors = screen.getAllByRole('textbox') as HTMLTextAreaElement[]
|
||||
expect(editors.map((editor) => JSON.parse(editor.value))).toEqual([
|
||||
'',
|
||||
'Thought 1',
|
||||
'',
|
||||
'Thought 2',
|
||||
'',
|
||||
'Thought 3',
|
||||
])
|
||||
for (const editor of editors) expect(editor).toHaveAttribute('readonly')
|
||||
expect(
|
||||
screen.getAllByText(/^(?:THOUGHT|FINAL ANSWER)$/).map((title) => title.textContent),
|
||||
).toEqual(['THOUGHT', 'THOUGHT', 'FINAL ANSWER'])
|
||||
expect(screen.getByText('FINAL ANSWER')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type EditorProps = {
|
||||
value?: string
|
||||
onChange?: (value: string | undefined) => void
|
||||
onMount?: (editor: ReturnType<typeof createEditor>, monaco: MonacoMock) => void
|
||||
options?: { readOnly?: boolean }
|
||||
}
|
||||
|
||||
type MonacoMock = { editor: { setTheme: () => void } }
|
||||
|
||||
const createEditor = () => ({
|
||||
getContentHeight: () => 56,
|
||||
onDidFocusEditorText: () => ({ dispose: () => {} }),
|
||||
onDidBlurEditorText: () => ({ dispose: () => {} }),
|
||||
})
|
||||
|
||||
const monaco: MonacoMock = { editor: { setTheme: () => {} } }
|
||||
|
||||
export function createMonacoEditorModuleMock() {
|
||||
function Editor({ value = '', onChange, onMount, options }: EditorProps) {
|
||||
const editorRef = useRef(createEditor())
|
||||
useEffect(() => {
|
||||
onMount?.(editorRef.current, monaco)
|
||||
}, [onMount])
|
||||
return (
|
||||
<textarea
|
||||
data-testid="monaco-editor"
|
||||
readOnly={options?.readOnly}
|
||||
value={value}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange?.(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Editor,
|
||||
default: Editor,
|
||||
DiffEditor: Editor,
|
||||
loader: { config: () => {} },
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user