test(web): migrate agent log modal specs to browser mode (#45677)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-06 04:07:38 +00:00
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()
})
})
+43
View File
@@ -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: () => {} },
}
}