eigent/test/unit/components/ChatBox/BottomBox/BottomBox.test.tsx
Douglas Lai 88f530358e
Some checks are pending
Pre-commit / pre-commit (push) Waiting to run
Test / Run Web + Local Brain Smoke (push) Waiting to run
Test / Run Frontend Guardrails (push) Waiting to run
Test / Run Python Tests (push) Waiting to run
Redesign two chat timeline view (#1856)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-20 23:48:54 +08:00

694 lines
21 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import BottomBox, { type BottomBoxProps } from '@/components/ChatBox/BottomBox';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/components/ChatBox/BottomBox/BoxFooter', () => ({
BoxFooter: ({ disabled }: { disabled?: boolean }) => (
<div
data-testid="project-setup-footer"
data-disabled={String(!!disabled)}
/>
),
}));
vi.mock('@/components/ChatBox/BottomBox/InputBox', () => ({
Inputbox: ({
value,
files = [],
header,
onFilesChange,
}: {
value?: string;
files?: { fileName: string; filePath: string }[];
header?: {
eyebrow?: string;
title?: string;
description?: string;
};
onFilesChange?: (files: { fileName: string; filePath: string }[]) => void;
}) => (
<div data-testid="text-composer" data-bottom-box-input-surface>
{header && (header.eyebrow || header.title || header.description) ? (
<section data-bottom-box-header>
{header.eyebrow}
{header.title}
{header.description}
</section>
) : null}
<span data-text-input>{value}</span>
<span data-input-actions />
{files.map((file) => (
<div key={file.filePath}>
{file.fileName}
<button
type="button"
aria-label={`Remove ${file.fileName}`}
onClick={() =>
onFilesChange?.(
files.filter((item) => item.filePath !== file.filePath)
)
}
/>
</div>
))}
</div>
),
}));
vi.mock('@/components/ChatBox/BottomBox/PickerPanel', () => ({
ConnectorPickerPanel: () => <div />,
SkillPickerPanel: () => <div />,
}));
const footerProps = {
sessionMode: 'single-agent' as const,
};
describe('BottomBox structure', () => {
it('keeps QueryBox above BoxMain and routes the legacy default to input', () => {
const onFilesChange = vi.fn();
const { container } = render(
<BottomBox
state="input"
queuedMessages={[{ id: 'queued-1', content: 'Follow up' }]}
inputProps={{
value: 'Draft query',
files: [{ fileName: 'brief.pdf', filePath: '/tmp/brief.pdf' }],
onFilesChange,
}}
{...footerProps}
/>
);
const root = container.querySelector('[data-bottom-box]');
const query = container.querySelector('[data-bottom-box-query]');
const main = container.querySelector('[data-bottom-box-main]');
const input = container.querySelector('[data-bottom-box-input]');
const footer = container.querySelector('[data-bottom-box-footer]');
expect(root).toBeInTheDocument();
expect(query).toBeInTheDocument();
expect(main).toBeInTheDocument();
expect(root?.firstElementChild).toBe(query);
expect(main).toContainElement(input);
expect(main).toContainElement(footer);
expect(input).toHaveAttribute('data-variant', 'input');
expect(
main?.querySelector(':scope > [data-bottom-box-header]')
).not.toBeInTheDocument();
expect(screen.getByTestId('text-composer')).toHaveTextContent(
'Draft query'
);
expect(screen.getByText('brief.pdf')).toBeInTheDocument();
expect(screen.getByTestId('project-setup-footer')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove brief.pdf' }));
expect(onFilesChange).toHaveBeenCalledWith([]);
});
it('renders the composer question inside InputBox instead of BoxHeader', () => {
const { container } = render(
<BottomBox
state="input"
variant={{
kind: 'input',
header: {
eyebrow: 'Input required',
title: 'Which format should I use?',
},
}}
inputProps={{ value: 'PDF' }}
{...footerProps}
/>
);
const main = container.querySelector('[data-bottom-box-main]');
const input = container.querySelector('[data-bottom-box-input]');
const header = input?.querySelector('[data-bottom-box-header]');
const surface = input?.querySelector('[data-bottom-box-input-surface]');
const textInput = surface?.querySelector('[data-text-input]');
const inputActions = surface?.querySelector('[data-input-actions]');
expect(
main?.querySelector(':scope > [data-bottom-box-header]')
).not.toBeInTheDocument();
expect(header).toBeInTheDocument();
expect(surface).toContainElement(header as HTMLElement);
expect(surface).toContainElement(textInput as HTMLElement);
expect(surface).toContainElement(inputActions as HTMLElement);
expect(header).toHaveTextContent('Input required');
expect(header).toHaveTextContent('Which format should I use?');
});
it('routes a confirmation request and keeps the project footer mounted', () => {
const onConfirm = vi.fn();
const onReject = vi.fn();
const { container } = render(
<BottomBox
state="running"
variant={{
kind: 'confirmation',
header: { title: 'Publish the report?' },
confirmLabel: 'Publish',
onConfirm,
onReject,
}}
{...footerProps}
/>
);
expect(screen.getByText('Publish the report?')).toBeInTheDocument();
expect(container.querySelector('[data-bottom-box-input]')).toHaveAttribute(
'data-variant',
'confirmation'
);
expect(screen.getByTestId('project-setup-footer')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Publish' }));
expect(onConfirm).toHaveBeenCalledOnce();
});
it('renders only the approval scopes supplied by the owner', async () => {
const onApprove = vi.fn();
const { container } = render(
<BottomBox
state="running"
variant={{
kind: 'approval',
header: {
eyebrow: 'Permission required',
title: 'Allow todo_write?',
contextItems: [
{
id: 'agent',
label: 'single_agent',
kind: 'agent',
},
{
id: 'operation',
label: 'mcp.tool.write',
kind: 'operation',
},
{
id: 'resource',
label: 'brave_search.web_search',
kind: 'external-context',
},
],
details: [
{
id: 'arguments',
label: 'Review arguments (secrets redacted)',
content: '{\n "item": "Write tests"\n}',
},
],
},
options: [{ scope: 'once', label: 'Approve once' }],
onApprove,
onReject: vi.fn(),
}}
{...footerProps}
/>
);
expect(container.querySelector('[data-bottom-box-input]')).toHaveAttribute(
'data-variant',
'approval'
);
expect(container.querySelector('[data-bottom-box-footer]')).toBeNull();
expect(screen.queryByTestId('project-setup-footer')).toBeNull();
const approvalSurface = container.querySelector(
'[data-approval-surface][data-bottom-box-input-surface]'
);
const approvalHeader = approvalSurface?.querySelector(
'[data-bottom-box-header]'
);
const approvalActions = approvalSurface?.querySelector(
'[data-approval-actions]'
);
expect(approvalSurface).toBeInTheDocument();
expect(approvalHeader).toBeInTheDocument();
expect(approvalHeader).not.toHaveTextContent('Permission required');
expect(approvalHeader).toHaveTextContent('Allow todo_write?');
expect(approvalSurface).toContainElement(approvalActions as HTMLElement);
expect(container.querySelector('[data-approval-actions]')).toHaveClass(
'justify-end'
);
const approvalButtons = within(approvalActions as HTMLElement).getAllByRole(
'button'
);
expect(approvalButtons).toHaveLength(2);
approvalButtons.forEach((button) =>
expect(button).toHaveClass('!rounded-full')
);
expect(container.querySelector('p, h1, h2, h3, h4, h5, h6')).toBeNull();
expect(
screen.queryByRole('button', { name: /always allow/i })
).not.toBeInTheDocument();
expect(
screen.queryByText('Review arguments (secrets redacted)')
).not.toBeInTheDocument();
expect(screen.queryByText('single_agent')).not.toBeInTheDocument();
expect(screen.queryByText('mcp.tool.write')).not.toBeInTheDocument();
expect(
screen.queryByText('brave_search.web_search')
).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Reject' })).not.toHaveFocus()
);
fireEvent.click(screen.getByRole('button', { name: 'Approve once' }));
expect(onApprove).toHaveBeenCalledWith('once');
});
it('renders a concise approval without eyebrow or scope descriptions', () => {
render(
<BottomBox
state="running"
variant={{
kind: 'approval',
header: {
eyebrow: 'Input required',
title: 'The agent wants to run send_message_to_user.',
},
options: [
{
scope: 'once',
label: 'Approve once',
description: 'Allow this action one time only.',
},
{
scope: 'space',
label: 'Always allow',
description: 'Allow this action in this Space from now on.',
},
],
onApprove: vi.fn(),
onReject: vi.fn(),
}}
{...footerProps}
/>
);
expect(screen.queryByText('Input required')).not.toBeInTheDocument();
expect(
screen.queryByText('Allow this action one time only.')
).not.toBeInTheDocument();
expect(
screen.queryByText('Allow this action in this Space from now on.')
).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Approve once' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Always allow' })
).toBeInTheDocument();
});
it('animates from the composer into a three-action approval variant', async () => {
const { container, rerender } = render(
<BottomBox
state="input"
inputProps={{ value: 'Draft query' }}
{...footerProps}
/>
);
expect(
container.querySelector(
'[data-bottom-box-variant-transition][data-variant="input"]'
)
).toBeInTheDocument();
expect(container.querySelector('[data-bottom-box-main]')).toHaveAttribute(
'data-layout-motion',
'smooth'
);
rerender(
<BottomBox
state="running"
variant={{
kind: 'approval',
header: {
eyebrow: 'Input required',
title: 'The agent wants to publish the report.',
},
options: [
{ scope: 'once', label: 'Approve once' },
{ scope: 'space', label: 'Always allow in Space' },
],
onApprove: vi.fn(),
onReject: vi.fn(),
}}
{...footerProps}
/>
);
expect(
container.querySelector(
'[data-bottom-box-variant-transition][data-variant="approval"]'
)
).toBeInTheDocument();
expect(container.querySelector('[data-bottom-box-footer]')).toBeNull();
expect(screen.queryByTestId('project-setup-footer')).toBeNull();
expect(container.querySelector('[data-bottom-box-main]')).toHaveAttribute(
'data-layout-motion',
'instant'
);
expect(screen.queryByText('Input required')).not.toBeInTheDocument();
expect(
screen.getByText('The agent wants to publish the report.')
).toBeInTheDocument();
expect(
within(
container.querySelector('[data-approval-actions]') as HTMLElement
).getAllByRole('button')
).toHaveLength(3);
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Reject' })).not.toHaveFocus()
);
});
it('routes controlled selection changes without owning event state', () => {
const onSelectionChange = vi.fn();
render(
<BottomBox
state="running"
variant={{
kind: 'selection',
header: { title: 'Choose a format' },
options: [
{ id: 'pdf', label: 'PDF' },
{ id: 'docx', label: 'Word document' },
],
selectedIds: [],
onSelectionChange,
onSubmit: vi.fn(),
}}
{...footerProps}
/>
);
fireEvent.click(screen.getByRole('radio', { name: 'PDF' }));
expect(onSelectionChange).toHaveBeenCalledWith(['pdf']);
});
it('keeps standard textarea chrome for non-question feedback', () => {
render(
<BottomBox
state="running"
variant={{
kind: 'feedback',
header: { title: 'Share feedback' },
value: '',
onChange: vi.fn(),
onSubmit: vi.fn(),
}}
{...footerProps}
/>
);
expect(screen.getByRole('textbox', { name: 'Feedback' })).toHaveClass(
'border',
'shadow-sm',
'focus-visible:ring-1'
);
});
it('routes feedback and structured form callbacks', async () => {
const onFeedbackChange = vi.fn();
const onFieldChange = vi.fn();
const { rerender } = render(
<BottomBox
state="running"
variant={{
kind: 'feedback',
presentation: 'question',
header: {
title: 'Question',
description: '### What should I **change**?',
},
value: '',
onChange: onFeedbackChange,
onSubmit: vi.fn(),
}}
{...footerProps}
/>
);
const questionLabel = screen.getByText('Question');
expect(questionLabel.parentElement).toHaveClass(
'text-body-sm',
'font-bold'
);
expect(
questionLabel
.closest('[data-bottom-box-header]')
?.querySelector('[data-bottom-box-question-icon]')
).toBeInTheDocument();
await waitFor(() =>
expect(
document.querySelector('[data-bottom-box-question-markdown] h3')
).toBeInTheDocument()
);
const questionPrompt = document.querySelector(
'[data-bottom-box-question-markdown] h3'
) as HTMLElement;
expect(questionPrompt).toHaveTextContent('What should I change?');
expect(questionPrompt.querySelector('strong')).toHaveTextContent('change');
expect(
questionPrompt.closest('[data-bottom-box-question-markdown]')
).toHaveClass(
'bottom-box-question-markdown',
'text-body-sm',
'text-ds-text-neutral-default-default'
);
expect(
questionPrompt.closest('[data-bottom-box-header]')
).toBeInTheDocument();
const questionTextarea = screen.getByRole('textbox', {
name: 'Feedback',
});
expect(questionTextarea).toHaveClass(
'border-none',
'shadow-none',
'focus-visible:ring-0',
'focus-visible:ring-offset-0'
);
fireEvent.change(questionTextarea, {
target: { value: 'Use a shorter title' },
});
expect(onFeedbackChange).toHaveBeenCalledWith('Use a shorter title');
rerender(
<BottomBox
state="running"
variant={{
kind: 'form',
header: { title: 'Report details' },
fields: [
{ id: 'audience', label: 'Audience', value: '', required: true },
],
onFieldChange,
onSubmit: vi.fn(),
}}
{...footerProps}
/>
);
const inputRegion = document.querySelector(
'[data-bottom-box-input][data-variant="form"]'
);
expect(inputRegion).toBeInTheDocument();
fireEvent.change(within(inputRegion as HTMLElement).getByRole('textbox'), {
target: { value: 'Executives' },
});
expect(onFieldChange).toHaveBeenCalledWith('audience', 'Executives');
});
it('accepts every controlled variant without requiring composer props', () => {
const variants: BottomBoxProps['variant'][] = [
{
kind: 'confirmation',
header: {},
onConfirm: vi.fn(),
onReject: vi.fn(),
},
{
kind: 'approval',
header: {},
options: [],
onApprove: vi.fn(),
onReject: vi.fn(),
},
{
kind: 'selection',
header: {},
options: [],
selectedIds: [],
onSelectionChange: vi.fn(),
onSubmit: vi.fn(),
},
{
kind: 'feedback',
header: {},
value: '',
onChange: vi.fn(),
onSubmit: vi.fn(),
},
{
kind: 'form',
header: {},
fields: [],
onFieldChange: vi.fn(),
onSubmit: vi.fn(),
},
{
kind: 'blocked',
header: { title: 'Unsupported request' },
message: 'Update the app before responding.',
},
{
kind: 'run_control',
header: { title: 'Run controls' },
runId: 'run-1',
state: 'read_only',
},
];
expect(variants.map((variant) => variant?.kind)).toEqual([
'confirmation',
'approval',
'selection',
'feedback',
'form',
'blocked',
'run_control',
]);
});
it('fails closed for a blocked mandatory interaction', () => {
render(
<BottomBox
state="running"
variant={{
kind: 'blocked',
header: { title: 'Unsupported request' },
message: 'This decision cannot be submitted safely.',
}}
{...footerProps}
/>
);
expect(screen.getByRole('alert')).toHaveTextContent(
'This decision cannot be submitted safely.'
);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByTestId('project-setup-footer')).toBeInTheDocument();
});
it('routes Resume and Cancel to the explicitly targeted interrupted Run', () => {
const onResume = vi.fn();
const onCancel = vi.fn();
const { rerender } = render(
<BottomBox
state="running"
variant={{
kind: 'run_control',
header: { title: 'Run interrupted' },
runId: 'run-interrupted',
state: 'interrupted',
onResume,
onCancel,
}}
{...footerProps}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Resume' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel Run' }));
expect(onResume).toHaveBeenCalledWith('run-interrupted');
expect(onCancel).toHaveBeenCalledWith('run-interrupted');
rerender(
<BottomBox
state="running"
variant={{
kind: 'run_control',
header: { title: 'Run interrupted' },
runId: 'run-interrupted',
state: 'interrupted',
disabled: true,
onResume,
onCancel,
}}
{...footerProps}
/>
);
expect(screen.getByRole('button', { name: 'Resume' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel Run' })).toBeDisabled();
});
it('shows locked transition labels and hides actions for read-only Runs', () => {
const common = {
kind: 'run_control' as const,
header: { title: 'Run lifecycle' },
runId: 'run-7',
};
const { rerender } = render(
<BottomBox
state="running"
variant={{ ...common, state: 'resuming' }}
{...footerProps}
/>
);
expect(screen.getByRole('button', { name: 'Resuming…' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel Run' })).toBeDisabled();
rerender(
<BottomBox
state="running"
variant={{ ...common, state: 'cancelling' }}
{...footerProps}
/>
);
expect(screen.getByRole('button', { name: 'Cancelling…' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Resume' })).toBeDisabled();
rerender(
<BottomBox
state="running"
variant={{ ...common, state: 'read_only' }}
{...footerProps}
/>
);
expect(screen.getByRole('status')).toHaveTextContent(
'This Run has finished, so its controls are no longer available.'
);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});