Playwright : new chat multi model test (#13402)

### What problem does this PR solve?

new test for chat multiple model and other chat parameters under
playwright

### Type of change

- [x] Other (please describe): new test/ data-testid
This commit is contained in:
Idriss Sbaaoui
2026-03-05 18:51:57 +08:00
committed by GitHub
parent d9785ea2ce
commit d90d6026af
13 changed files with 912 additions and 69 deletions

View File

@ -108,7 +108,15 @@ export function LargeModelFormField({
);
}
export function LargeModelFormFieldWithoutFilter() {
type LargeModelFormFieldWithoutFilterProps = Pick<
NextInnerLLMSelectProps,
'triggerTestId' | 'optionTestIdPrefix'
>;
export function LargeModelFormFieldWithoutFilter({
triggerTestId,
optionTestIdPrefix,
}: LargeModelFormFieldWithoutFilterProps = {}) {
const form = useFormContext();
return (
@ -118,7 +126,11 @@ export function LargeModelFormFieldWithoutFilter() {
render={({ field }) => (
<FormItem>
<FormControl>
<NextLLMSelect {...field} />
<NextLLMSelect
{...field}
triggerTestId={triggerTestId}
optionTestIdPrefix={optionTestIdPrefix}
/>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -15,58 +15,76 @@ export interface NextInnerLLMSelectProps {
disabled?: boolean;
filter?: string;
showSpeech2TextModel?: boolean;
triggerTestId?: string;
optionTestIdPrefix?: string;
}
const NextInnerLLMSelect = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
NextInnerLLMSelectProps
>(({ value, disabled, filter, showSpeech2TextModel = false }, ref) => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
>(
(
{
value,
disabled,
filter,
showSpeech2TextModel = false,
triggerTestId,
optionTestIdPrefix,
},
ref,
) => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const ttsModel = useMemo(() => {
return showSpeech2TextModel ? [LlmModelType.Speech2text] : [];
}, [showSpeech2TextModel]);
const ttsModel = useMemo(() => {
return showSpeech2TextModel ? [LlmModelType.Speech2text] : [];
}, [showSpeech2TextModel]);
const modelTypes = useMemo(() => {
if (filter === LlmModelType.Chat) {
return [LlmModelType.Chat];
} else if (filter === LlmModelType.Image2text) {
return [LlmModelType.Image2text, ...ttsModel];
} else {
return [LlmModelType.Chat, LlmModelType.Image2text, ...ttsModel];
}
}, [filter, ttsModel]);
const modelTypes = useMemo(() => {
if (filter === LlmModelType.Chat) {
return [LlmModelType.Chat];
} else if (filter === LlmModelType.Image2text) {
return [LlmModelType.Image2text, ...ttsModel];
} else {
return [LlmModelType.Chat, LlmModelType.Image2text, ...ttsModel];
}
}, [filter, ttsModel]);
const modelOptions = useComposeLlmOptionsByModelTypes(modelTypes);
const modelOptions = useComposeLlmOptionsByModelTypes(modelTypes);
return (
<Select disabled={disabled} value={value}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<SelectTrigger
onClick={(e) => {
e.preventDefault();
setIsPopoverOpen(true);
}}
ref={ref}
>
<SelectValue placeholder={t('common.pleaseSelect')}>
{
modelOptions
.flatMap((x) => x.options)
.find((x) => x.value === value)?.label
}
</SelectValue>
</SelectTrigger>
</PopoverTrigger>
<PopoverContent side={'left'}>
<LlmSettingFieldItems options={modelOptions}></LlmSettingFieldItems>
</PopoverContent>
</Popover>
</Select>
);
});
return (
<Select disabled={disabled} value={value}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<SelectTrigger
onClick={(e) => {
e.preventDefault();
setIsPopoverOpen(true);
}}
ref={ref}
data-testid={triggerTestId}
>
<SelectValue placeholder={t('common.pleaseSelect')}>
{
modelOptions
.flatMap((x) => x.options)
.find((x) => x.value === value)?.label
}
</SelectValue>
</SelectTrigger>
</PopoverTrigger>
<PopoverContent side={'left'}>
<LlmSettingFieldItems
options={modelOptions}
llmOptionTestIdPrefix={optionTestIdPrefix}
></LlmSettingFieldItems>
</PopoverContent>
</Popover>
</Select>
);
},
);
NextInnerLLMSelect.displayName = 'LLMSelect';

View File

@ -7,6 +7,8 @@ import { RAGFlowFormItem } from '../ragflow-form';
export type LLMFormFieldProps = {
options?: any[];
name?: string;
testId?: string;
optionTestIdPrefix?: string;
};
export const useModelOptions = () => {
@ -19,13 +21,22 @@ export const useModelOptions = () => {
};
};
export function LLMFormField({ options, name }: LLMFormFieldProps) {
export function LLMFormField({
options,
name,
testId,
optionTestIdPrefix,
}: LLMFormFieldProps) {
const { t } = useTranslation();
const { modelOptions } = useModelOptions();
return (
<RAGFlowFormItem name={name || 'llm_id'} label={t('chat.model')}>
<SelectWithSearch options={options || modelOptions}></SelectWithSearch>
<SelectWithSearch
options={options || modelOptions}
testId={testId}
optionTestIdPrefix={optionTestIdPrefix}
></SelectWithSearch>
</RAGFlowFormItem>
);
}

View File

@ -29,6 +29,8 @@ interface LlmSettingFieldItemsProps {
prefix?: string;
options?: any[];
llmId?: string;
llmSelectTestId?: string;
llmOptionTestIdPrefix?: string;
showFields?: Array<
| 'temperature'
| 'top_p'
@ -67,6 +69,8 @@ export const LlmSettingSchema = {
export function LlmSettingFieldItems({
prefix,
options,
llmSelectTestId,
llmOptionTestIdPrefix,
showFields = [
'temperature',
'top_p',
@ -134,6 +138,8 @@ export function LlmSettingFieldItems({
<LLMFormField
options={options}
name={llmId ?? getFieldWithPrefix('llm_id')}
testId={llmSelectTestId}
optionTestIdPrefix={llmOptionTestIdPrefix}
></LLMFormField>
<FormField
control={form.control}

View File

@ -234,6 +234,7 @@ export function NextMessageInput({
variant="transparent"
className="rounded-sm border-0"
disabled={isUploading || sendLoading}
data-testid="chat-detail-attach"
>
<Paperclip className="size-3.5" />
<span className="sr-only">Attach file</span>
@ -248,6 +249,7 @@ export function NextMessageInput({
variant={enableThinking ? 'accent' : 'transparent'}
className="border-0 h-7 text-sm"
onClick={handleThinkingToggle}
data-testid="chat-detail-thinking-toggle"
>
<Atom />
<span>Thinking</span>
@ -261,6 +263,7 @@ export function NextMessageInput({
size="icon-xs"
className="border-0"
onClick={handleInternetToggle}
data-testid="chat-detail-internet-toggle"
>
<Globe />
</Button>
@ -281,6 +284,7 @@ export function NextMessageInput({
onOk={(value) => {
setAudioInputValue(value);
}}
testId="chat-detail-audio-toggle"
/>
<Button
@ -288,6 +292,7 @@ export function NextMessageInput({
disabled={
sendDisabled || isUploading || sendLoading || !value.trim()
}
data-testid="chat-detail-send"
>
<Send />
<span className="sr-only">Send message</span>

View File

@ -49,6 +49,7 @@ export type SelectWithSearchFlagProps = {
placeholder?: string;
emptyData?: string;
testId?: string;
optionTestIdPrefix?: string;
};
function findLabelWithoutOptions(
@ -82,6 +83,7 @@ export const SelectWithSearch = forwardRef<
placeholder = t('common.selectPlaceholder'),
emptyData = t('common.noDataFound'),
testId,
optionTestIdPrefix,
},
ref,
) => {
@ -218,7 +220,11 @@ export const SelectWithSearch = forwardRef<
value={option.value}
disabled={option.disabled}
onSelect={handleSelect}
data-testid="combobox-option"
data-testid={
optionTestIdPrefix && option.value
? `${optionTestIdPrefix}${option.value}`
: 'combobox-option'
}
className={
value === option.value ? 'bg-bg-card' : ''
}
@ -240,7 +246,11 @@ export const SelectWithSearch = forwardRef<
value={group.value}
disabled={group.disabled}
onSelect={handleSelect}
data-testid="combobox-option"
data-testid={
optionTestIdPrefix && group.value
? `${optionTestIdPrefix}${group.value}`
: 'combobox-option'
}
className={cn('mb-1 min-h-10 ', {
'bg-bg-card ': value === group.value,
})}

View File

@ -217,8 +217,10 @@ const VoiceInputBox = ({
};
export const AudioButton = ({
onOk,
testId,
}: {
onOk?: (transcript: string) => void;
testId?: string;
}) => {
// const [showInputBox, setShowInputBox] = useState(false);
const [isRecording, setIsRecording] = useState(false);
@ -415,6 +417,7 @@ export const AudioButton = ({
'animate-pulse !bg-state-success/20 text-state-success rounded-full',
)}
disabled={isProcessing}
data-testid={testId}
>
{isProcessing ? (
<Loader2 size={16} className=" animate-spin" />

View File

@ -108,6 +108,7 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
disabled={!hasSingleChatBox}
variant={'ghost'}
size="icon-sm"
data-testid="chat-settings"
>
<LucideSettings />
</Button>
@ -116,7 +117,10 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
}
return (
<section className="w-[440px] flex flex-col">
<section
className="w-[440px] flex flex-col"
data-testid="chat-detail-settings"
>
<div className="p-5 pb-2 flex justify-between items-center text-base">
{t('chat.chatSetting')}
@ -125,6 +129,7 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
size="icon-sm"
className="border-0"
onClick={switchSettingVisible}
data-testid="chat-detail-settings-close"
>
<LucidePanelRightClose
className="size-4 cursor-pointer"
@ -149,7 +154,11 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
</ScrollArea>
<div className="p-5 pt-4 space-x-5 text-right">
<Button variant={'outline'} onClick={switchSettingVisible}>
<Button
variant={'outline'}
onClick={switchSettingVisible}
data-testid="chat-detail-settings-cancel"
>
{t('chat.cancel')}
</Button>
<SavingButton loading={loading}></SavingButton>

View File

@ -162,13 +162,21 @@ const ChatCard = forwardRef(function ChatCard(
}, [id, sendLoading, setLoading]);
return (
<Card className="bg-transparent border flex-1 flex flex-col">
<Card
className="bg-transparent border flex-1 flex flex-col"
data-testid="chat-detail-multimodel-card"
data-card-index={idx}
data-card-key={id}
>
<CardHeader className="border-b-0.5 px-5 py-3">
<CardTitle className="flex justify-between items-center">
<div className="flex items-center gap-3">
<span className="text-base">{idx + 1}</span>
<Form {...form}>
<LargeModelFormFieldWithoutFilter></LargeModelFormFieldWithoutFilter>
<LargeModelFormFieldWithoutFilter
triggerTestId="chat-detail-multimodel-card-model-select"
optionTestIdPrefix="chat-detail-llm-option-"
></LargeModelFormFieldWithoutFilter>
</Form>
</div>
<div className="space-x-2">
@ -179,6 +187,8 @@ const ChatCard = forwardRef(function ChatCard(
size="icon-sm"
disabled={isEmpty(llmId)}
onClick={handleApplyConfig}
data-testid="chat-detail-multimodel-card-apply"
data-card-index={idx}
>
<ListCheck />
</Button>
@ -192,11 +202,18 @@ const ChatCard = forwardRef(function ChatCard(
variant="ghost"
size="icon-sm"
onClick={handleRemoveChatBox}
data-testid="chat-detail-multimodel-card-remove"
data-card-index={idx}
>
<Trash2 />
</Button>
) : (
<Button variant="ghost" size="icon-sm" onClick={addChatBox}>
<Button
variant="ghost"
size="icon-sm"
onClick={addChatBox}
data-testid="chat-detail-multimodel-add-card"
>
<Plus />
</Button>
)}
@ -314,7 +331,10 @@ export function MultipleChatBox({
return (
<section className="h-full flex flex-col px-5">
<div className="flex gap-4 flex-1 px-5 pb-14 min-h-0">
<div
className="flex gap-4 flex-1 px-5 pb-14 min-h-0"
data-testid="chat-detail-multimodel-grid"
>
{chatBoxIds.map((id, idx) => (
<ChatCard
key={id}

View File

@ -26,12 +26,15 @@ export function ConversationDropdown({
const { t } = useTranslation();
const { setConversationBoth } = useChatUrlParams();
const { removeConversation } = useRemoveConversation();
const { isNew } = useGetChatSearchParams();
const { conversationId, isNew } = useGetChatSearchParams();
const handleDelete: MouseEventHandler<HTMLDivElement> =
useCallback(async () => {
if (isNew === 'true' && removeTemporaryConversation) {
removeTemporaryConversation(conversation.id);
if (conversationId === conversation.id) {
setConversationBoth('', '');
}
} else {
const code = await removeConversation([conversation.id]);
if (code === 0) {
@ -40,6 +43,7 @@ export function ConversationDropdown({
}
}, [
conversation.id,
conversationId,
isNew,
removeConversation,
removeTemporaryConversation,
@ -59,6 +63,8 @@ export function ConversationDropdown({
onClick={(e) => {
e.stopPropagation();
}}
data-testid="chat-detail-session-delete"
data-session-id={conversation.id}
>
{t('common.delete')} <Trash2 />
</DropdownMenuItem>

View File

@ -92,10 +92,17 @@ export default function Chat() {
if (isDebugMode) {
return (
<section className="pt-5 pb-16 h-[100vh] flex flex-col">
<section
className="pt-5 pb-16 h-[100vh] flex flex-col"
data-testid="chat-detail-multimodel-root"
>
<header className="px-10 pb-5">
<div className="mb-5">
<Button variant="outline" onClick={switchDebugMode}>
<Button
variant="outline"
onClick={switchDebugMode}
data-testid="chat-detail-multimodel-back"
>
<LucideArrowBigLeft />
<span>{t('common.back')}</span>
</Button>
@ -138,7 +145,7 @@ export default function Chat() {
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button onClick={showEmbedModal}>
<Button onClick={showEmbedModal} data-testid="chat-detail-embed-open">
<LucideSend />
{t('common.embedIntoSite')}
</Button>
@ -157,7 +164,11 @@ export default function Chat() {
>
<CardTitle className="flex justify-between items-center text-base gap-2">
<div className="truncate">{currentConversationName}</div>
<Button variant={'ghost'} onClick={switchDebugMode}>
<Button
variant={'ghost'}
onClick={switchDebugMode}
data-testid="chat-detail-multimodel-toggle"
>
<LucideArrowUpRight /> {t('chat.multipleModels')}
</Button>
</CardTitle>

View File

@ -20,6 +20,7 @@ import {
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatUrlParams } from '../hooks/use-chat-url';
import { useHandleClickConversationCard } from '../hooks/use-click-card';
import { useSelectDerivedConversationList } from '../hooks/use-select-conversation-list';
import { ConversationDropdown } from './conversation-dropdown';
@ -40,6 +41,8 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
const { data } = useFetchDialog();
const { visible, switchVisible } = useSetModalState(true);
const { removeConversation } = useRemoveConversation();
const { setConversationBoth } = useChatUrlParams();
const { conversationId } = useGetChatSearchParams();
// Selection mode state
const [selectionMode, setSelectionMode] = useState(false);
@ -82,14 +85,52 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
// Batch delete
const handleBatchDelete = useCallback(async () => {
if (selectedIds.size > 0) {
await removeConversation(Array.from(selectedIds));
exitSelectionMode();
if (selectedIds.size === 0) {
return;
}
}, [selectedIds, removeConversation, exitSelectionMode]);
const selectedIdList = Array.from(selectedIds);
const currentConversationDeleted = conversationId
? selectedIdList.includes(conversationId)
: false;
const temporaryIdSet = new Set(
conversationList.filter((item) => item.is_new).map((item) => item.id),
);
const persistedIds: string[] = [];
selectedIdList.forEach((id) => {
if (temporaryIdSet.has(id)) {
removeTemporaryConversation(id);
} else {
persistedIds.push(id);
}
});
let removeCode = -1;
if (persistedIds.length > 0) {
removeCode = await removeConversation(persistedIds);
}
if (currentConversationDeleted && conversationId) {
const currentIsTemporary = temporaryIdSet.has(conversationId);
const currentPersistedDeleted =
persistedIds.includes(conversationId) && removeCode === 0;
if (currentIsTemporary || currentPersistedDeleted) {
setConversationBoth('', '');
}
}
exitSelectionMode();
}, [
selectedIds,
conversationId,
conversationList,
setConversationBoth,
removeTemporaryConversation,
removeConversation,
exitSelectionMode,
]);
const selectedCount = useMemo(() => selectedIds.size, [selectedIds]);
const { conversationId } = useGetChatSearchParams();
if (!visible) {
return (
@ -99,6 +140,7 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
size="icon-sm"
className="border-0"
onClick={switchVisible}
data-testid="chat-detail-sessions-open"
>
<RAGFlowAvatar
avatar={data.icon}
@ -111,7 +153,11 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
}
return (
<aside className="p-5 w-[296px] flex flex-col" role="complementary">
<aside
className="p-5 w-[296px] flex flex-col"
role="complementary"
data-testid="chat-detail-sessions"
>
<header className="flex items-center text-base justify-between gap-2">
<div className="flex gap-3 items-center min-w-0">
<RAGFlowAvatar
@ -128,6 +174,7 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
size="icon-sm"
className="border-0"
onClick={switchVisible}
data-testid="chat-detail-sessions-close"
>
<LucidePanelLeftClose />
</Button>
@ -147,7 +194,12 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
<div className="flex items-center gap-2">
{selectionMode ? (
// Exit selection mode
<Button variant="ghost" size="icon-xs" onClick={exitSelectionMode}>
<Button
variant="ghost"
size="icon-xs"
onClick={exitSelectionMode}
data-testid="chat-detail-session-selection-exit"
>
<LucideUndo2 size={16} />
</Button>
) : (
@ -156,6 +208,7 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
variant="ghost"
size="icon-xs"
onClick={addTemporaryConversation}
data-testid="chat-detail-session-new"
>
<LucidePlus className="h-4 w-4" />
</Button>
@ -171,8 +224,15 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
count: selectedCount,
}),
}}
testId="chat-detail-session-batch-delete-dialog"
confirmButtonTestId="chat-detail-session-batch-delete-confirm"
cancelButtonTestId="chat-detail-session-batch-delete-cancel"
>
<Button variant="delete" size="icon-xs">
<Button
variant="delete"
size="icon-xs"
data-testid="chat-detail-session-batch-delete"
>
<LucideTrash2 />
</Button>
</ConfirmDeleteDialog>
@ -181,6 +241,11 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
variant="ghost"
size="icon-xs"
onClick={selectionMode ? toggleSelectAll : toggleSelectionMode}
data-testid={
selectionMode
? 'chat-detail-session-select-all'
: 'chat-detail-session-selection-enable'
}
>
{selectionMode ? <LucideListChecks /> : <LucideCopyX />}
</Button>
@ -192,6 +257,7 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
<SearchInput
onChange={handleInputChange}
value={searchString}
data-testid="chat-detail-session-search"
></SearchInput>
</div>
@ -204,11 +270,14 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
className="py-2"
role="option"
aria-selected={selectedIds.has(x.id)}
data-session-id={x.id}
>
<label className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(x.id)}
onCheckedChange={() => toggleSelection(x.id)}
data-testid="chat-detail-session-checkbox"
data-session-id={x.id}
/>
<span className="truncate">{x.name}</span>
@ -232,6 +301,8 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
type="button"
className="focus-visible:outline-none px-3 py-2 text-left flex-1 truncate"
onClick={() => handleConversationCardClick(x.id, x.is_new)}
data-testid="chat-detail-session-item"
data-session-id={x.id}
>
{x.name}
</button>
@ -240,7 +311,10 @@ export function Sessions({ handleConversationCardClick }: SessionProps) {
conversation={x}
removeTemporaryConversation={removeTemporaryConversation}
>
<MoreButton></MoreButton>
<MoreButton
data-testid="chat-detail-session-actions"
data-session-id={x.id}
></MoreButton>
</ConversationDropdown>
</li>
))}