Files
lobe-chat/e2e/src/steps/agent/conversation-mgmt.steps.ts
T
Innei b4b1205ee9 ♻️ refactor(modal): migrate confirm modals to @lobehub/ui/base-ui (Phase 1) (#15259)
* ♻️ refactor(modal): migrate confirm modals to @lobehub/ui/base-ui

Replace all `App.useApp().modal.confirm`, `Modal.confirm` and `AntModal.confirm`
call sites with the headless `confirmModal` from `@lobehub/ui/base-ui`, dropping
antd-only props (`centered`, `type`, `width`, `okButtonProps.type='primary'`,
`okButtonProps.loading`, `classNames.root`) that the base-ui imperative API does
not accept.

- 82 files touched; `modal.confirm`/`Modal.confirm` call sites now zero
- `PageEditor/store/action.ts`: drop `modal` arg from `handleDelete`
- `ResourceManager/useUploadFolder`: replace dynamic `import('antd').Modal`
- `Eval/DatasetsTab`: migrate `modal.success` to `confirmModal`

Part of LOBE-9645 Phase 1.

* ♻️ refactor(ui): migrate select/modal call sites to @lobehub/ui/base-ui

- Convert imperative-modal factories (createXxxModal + Content split) for apikey,
  creds (Create/Edit/View), provider (CreateNewProvider), and messenger LinkModal.
- Switch Select usages to base-ui Select (Messenger AgentSelect, provider sdkType).
- Restructure CreateNewProvider form to vertical layout with manual section titles
  for tighter spacing; drop FormModal/Form group nesting.
- Standardize small ActionIcon sizing via DESKTOP_HEADER_ICON_SMALL_SIZE
  (WideScreenButton, ToggleRightPanelButton, ContextDropdown, AddNewProvider).
- Fix missing title on ResourceManager delete confirm modal so the header
  (title + close X) renders.
- Update react skill and AGENTS.md to require base-ui priority over root @lobehub/ui
  / antd; expand component table and Common Mistakes with explicit base-ui rules.

* ♻️ refactor(ui): swap antd Select to base-ui Select and migrate createStyles to createStaticStyles

*  test: update test mocks for base-ui confirmModal migration

*  test(e2e): switch delete confirm selector to base-ui dialog role
2026-05-28 02:46:27 +08:00

643 lines
23 KiB
TypeScript

/**
* Agent Conversation Management Steps
*
* Step definitions for Agent conversation management E2E tests
* - Create new conversation
* - Switch conversations
* - Rename conversation
* - Delete conversation
* - Search conversations
*/
import { Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import type { CustomWorld } from '../../support/world';
// ============================================
// Given Steps
// ============================================
Given('用户已有一个对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 创建一个对话...');
// Send a message to create a conversation
const chatInputs = this.page.locator('[data-testid="chat-input"]');
const count = await chatInputs.count();
let chatInputContainer = chatInputs.first();
for (let i = 0; i < count; i++) {
const elem = chatInputs.nth(i);
const box = await elem.boundingBox();
if (box && box.width > 0 && box.height > 0) {
chatInputContainer = elem;
break;
}
}
await chatInputContainer.click();
await this.page.waitForTimeout(300);
await this.page.keyboard.type('hello', { delay: 30 });
await this.page.keyboard.press('Enter');
// Wait for response
await this.page.waitForTimeout(2000);
// Store the current conversation title for later reference
const topicItems = this.page.locator('.ant-menu-item, [class*="NavItem"]');
const topicCount = await topicItems.count();
console.log(` 📍 Found ${topicCount} topic items after creating conversation`);
console.log(' ✅ 已创建一个对话');
});
Given('用户有多个对话历史', async function (this: CustomWorld) {
console.log(' 📍 Step: 创建多个对话...');
// Create first conversation
const chatInputs = this.page.locator('[data-testid="chat-input"]');
let chatInputContainer = chatInputs.first();
const count = await chatInputs.count();
for (let i = 0; i < count; i++) {
const elem = chatInputs.nth(i);
const box = await elem.boundingBox();
if (box && box.width > 0 && box.height > 0) {
chatInputContainer = elem;
break;
}
}
// First conversation - use "测试" content for search test
await chatInputContainer.click();
await this.page.waitForTimeout(300);
await this.page.keyboard.type('测试对话内容', { delay: 30 });
await this.page.keyboard.press('Enter');
await this.page.waitForTimeout(2000);
// Store first conversation reference
this.testContext.firstConversation = 'first';
// Create new topic and second conversation
console.log(' 📍 Creating second conversation...');
const addTopicButton = this.page.locator('svg.lucide-message-square-plus').locator('..');
if ((await addTopicButton.count()) > 0) {
await addTopicButton.first().click();
await this.page.waitForTimeout(1000);
// Send message in second conversation - different content
await chatInputContainer.click();
await this.page.waitForTimeout(300);
await this.page.keyboard.type('hello world', { delay: 30 });
await this.page.keyboard.press('Enter');
await this.page.waitForTimeout(2000);
}
console.log(' ✅ 已创建多个对话');
});
// ============================================
// When Steps
// ============================================
When('用户点击新建对话按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 点击新建对话按钮...');
// The add topic button uses MessageSquarePlusIcon from lucide-react
const addTopicButton = this.page.locator('svg.lucide-message-square-plus').locator('..');
if ((await addTopicButton.count()) > 0) {
await addTopicButton.first().click();
console.log(' ✅ 已点击新建对话按钮');
} else {
// Fallback: look for button with "新建" or "add" in title
const addButton = this.page.locator('button[title*="新建"], button[title*="add"]');
if ((await addButton.count()) > 0) {
await addButton.first().click();
console.log(' ✅ 已点击新建对话按钮 (fallback)');
} else {
throw new Error('New topic button not found');
}
}
await this.page.waitForTimeout(500);
});
When('用户点击另一个对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 点击另一个对话...');
// Check if we're on the home page (has Recent Topics section)
const recentTopicsSection = this.page.locator('text=Recent Topics');
const isOnHomePage = (await recentTopicsSection.count()) > 0;
console.log(` 📍 Is on home page: ${isOnHomePage}`);
if (isOnHomePage) {
// Click the second topic card in Recent Topics section
// Cards are wrapped in Link components and contain "Hello! I am a mock AI" text from the mock
const recentTopicCards = this.page.locator('a[href*="topic="]');
const cardCount = await recentTopicCards.count();
console.log(` 📍 Found ${cardCount} recent topic cards (by href)`);
if (cardCount >= 2) {
// Click the second card (different from current topic)
await recentTopicCards.nth(1).click();
console.log(' ✅ 已点击首页 Recent Topics 中的另一个对话');
await this.page.waitForTimeout(2000);
return;
}
// Fallback: try to find by text content
const topicTextCards = this.page.locator('text=Hello! I am a mock AI');
const textCardCount = await topicTextCards.count();
console.log(` 📍 Found ${textCardCount} topic cards by text`);
if (textCardCount >= 2) {
await topicTextCards.nth(1).click();
console.log(' ✅ 已点击首页 Recent Topics 中的另一个对话 (by text)');
await this.page.waitForTimeout(2000);
return;
}
}
// Fallback: try to find topic items in the sidebar
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
// Click the second topic (first one is current/active)
if (topicCount >= 2) {
await sidebarTopics.nth(1).click();
console.log(' ✅ 已点击另一个对话');
} else {
throw new Error('Not enough topics to switch');
}
await this.page.waitForTimeout(500);
});
When('用户右键点击对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击对话...');
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
if (topicCount > 0) {
await sidebarTopics.first().click({ button: 'right' });
console.log(' ✅ 已右键点击对话');
} else {
throw new Error('No topics found to right-click');
}
await this.page.waitForTimeout(500);
});
When('用户右键点击一个对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击一个对话...');
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
// Store the topic text for later verification
if (topicCount > 0) {
const topicText = await sidebarTopics.first().textContent();
this.testContext.deletedTopicTitle = topicText?.slice(0, 30);
await sidebarTopics.first().click({ button: 'right' });
console.log(` ✅ 已右键点击对话: "${topicText?.slice(0, 30)}..."`);
} else {
throw new Error('No topics found to right-click');
}
await this.page.waitForTimeout(500);
});
When('用户选择重命名选项', async function (this: CustomWorld) {
console.log(' 📍 Step: 选择重命名选项...');
// First, close any open context menu by clicking elsewhere
await this.page.click('body', { position: { x: 500, y: 300 } });
await this.page.waitForTimeout(300);
// Instead of using right-click context menu, use the "..." dropdown menu
// which appears when hovering over a topic item
const topicItems = this.page.locator('[data-testid="topic-item"]');
const topicCount = await topicItems.count();
console.log(` 📍 Found ${topicCount} topic items`);
if (topicCount > 0) {
// Hover on the first topic to reveal the "..." action button
const firstTopic = topicItems.first();
await firstTopic.hover();
console.log(' 📍 Hovering on topic item...');
await this.page.waitForTimeout(500);
// The "..." button should now be visible INSIDE the topic item
// Important: we must find the icon WITHIN the hovered topic, not the global one
// The topic item has a specific structure with nav-item-actions
const moreButtonInTopic = firstTopic.locator('svg.lucide-ellipsis, svg.lucide-more-horizontal');
const moreButtonCount = await moreButtonInTopic.count();
console.log(` 📍 Found ${moreButtonCount} more buttons inside topic`);
if (moreButtonCount > 0) {
// Click the "..." button to open dropdown menu
await moreButtonInTopic.first().click();
console.log(' 📍 Clicked ... button inside topic');
await this.page.waitForTimeout(500);
} else {
// Fallback: try to find it by looking at the actions container
console.log(' 📍 Trying alternative: looking for actions container...');
// Debug: print the topic item HTML structure
const topicHTML = await firstTopic.evaluate((el) => el.outerHTML.slice(0, 500));
console.log(` 📍 Topic HTML: ${topicHTML}`);
// The actions might be in a sibling or parent element
// Try finding any ellipsis icon that's near the topic
const allEllipsis = this.page.locator('svg.lucide-ellipsis');
const ellipsisCount = await allEllipsis.count();
console.log(` 📍 Total ellipsis icons on page: ${ellipsisCount}`);
// Skip the first one (which is the global topic list menu)
// and click the second one (which should be in the topic item)
if (ellipsisCount > 1) {
await allEllipsis.nth(1).click();
console.log(' 📍 Clicked second ellipsis icon');
await this.page.waitForTimeout(500);
}
}
}
// Now find the rename option in the dropdown menu
const renameOption = this.page.getByRole('menuitem', { exact: true, name: /^(Rename|重命名)$/ });
await expect(renameOption).toBeVisible({ timeout: 5000 });
console.log(' 📍 Found rename menu item');
// Click the rename option
await renameOption.click();
console.log(' 📍 Clicked rename menu item');
// Wait for the popover/input to appear
await this.page.waitForTimeout(500);
// Check if input appeared
const inputCount = await this.page.locator('input').count();
console.log(` 📍 After click: ${inputCount} inputs on page`);
console.log(' ✅ 已选择重命名选项');
});
When('用户输入新的对话名称 {string}', async function (this: CustomWorld, newName: string) {
console.log(` 📍 Step: 输入新名称 "${newName}"...`);
// Debug: check what's on the page
const debugInfo = await this.page.evaluate(() => {
const allInputs = document.querySelectorAll('input');
const allPopovers = document.querySelectorAll('[class*="popover"], .ant-popover');
const focusedElement = document.activeElement;
return {
focusedClass: focusedElement?.className,
focusedTag: focusedElement?.tagName,
inputCount: allInputs.length,
inputTags: Array.from(allInputs).map((i) => ({
className: i.className,
placeholder: i.placeholder,
type: i.type,
visible: i.offsetParent !== null,
})),
popoverCount: allPopovers.length,
};
});
console.log(' 📍 Debug info:', JSON.stringify(debugInfo, null, 2));
// Wait a short moment for the popover to render
await this.page.waitForTimeout(300);
// The rename UI can render as a dialog/modal in CI, not only as a popover.
const renameInputSelectors = [
'[role="dialog"] input[type="text"]',
'.ant-modal input[type="text"]',
'[data-testid="editing-popover"] input',
'.ant-popover-inner input',
'.ant-popover-content input',
'.ant-popover input',
'input[type="text"]:visible',
];
let renameInput = null;
// Wait for any rename input to appear
for (const selector of renameInputSelectors) {
try {
const locator = this.page.locator(selector).first();
await locator.waitFor({ state: 'visible', timeout: 2000 });
renameInput = locator;
console.log(` 📍 Found input with selector: ${selector}`);
break;
} catch {
// Try next selector
}
}
if (!renameInput) {
// Fallback: find any visible input that's not the search or chat input
console.log(' 📍 Trying fallback: finding any visible input...');
const allInputs = this.page.locator('input:visible');
const count = await allInputs.count();
console.log(` 📍 Found ${count} visible inputs`);
for (let i = 0; i < count; i++) {
const input = allInputs.nth(i);
const placeholder = await input.getAttribute('placeholder').catch(() => '');
const testId = await input.getAttribute('data-testid').catch(() => '');
// Skip search inputs and chat inputs
if (placeholder?.includes('Search') || placeholder?.includes('搜索')) continue;
if (testId === 'chat-input') continue;
// Prefer inputs rendered inside rename containers.
const isInRenameContainer = await input.evaluate((el) => {
return (
el.closest('[role="dialog"]') !== null ||
el.closest('.ant-modal') !== null ||
el.closest('.ant-popover') !== null ||
el.closest('[class*="popover"]') !== null
);
});
if (isInRenameContainer || count === 1) {
renameInput = input;
console.log(` 📍 Found candidate input at index ${i}`);
break;
}
}
}
if (renameInput) {
// Clear and fill the input
await renameInput.click();
await renameInput.clear();
await renameInput.fill(newName);
console.log(` 📍 Filled input with "${newName}"`);
const saveButton = this.page
.locator('[role="dialog"]')
.getByRole('button', { exact: true, name: /^(Save|保存)$/ })
.first();
try {
await saveButton.waitFor({ state: 'visible', timeout: 1000 });
await saveButton.click();
console.log(' 📍 Clicked save button');
} catch {
// Popover-based rename UIs still confirm with Enter.
await renameInput.press('Enter');
console.log(' 📍 Confirmed rename with Enter');
}
console.log(` ✅ 已输入新名称 "${newName}"`);
} else {
// Last resort: the input should have autoFocus, so keyboard should work
console.log(' ⚠️ Could not find rename input element, using keyboard fallback...');
// Select all and replace
await this.page.keyboard.press('Meta+A');
await this.page.waitForTimeout(50);
await this.page.keyboard.type(newName, { delay: 20 });
await this.page.keyboard.press('Enter');
console.log(` ✅ 已通过键盘输入新名称 "${newName}"`);
}
// Wait for the rename to be saved
await this.page.waitForTimeout(1000);
});
When('用户选择删除选项', async function (this: CustomWorld) {
console.log(' 📍 Step: 选择删除选项...');
// The context menu should be visible with "delete" option
// Support both English and Chinese
const deleteOption = this.page.getByRole('menuitem', { exact: true, name: /^(Delete|删除)$/ });
await expect(deleteOption).toBeVisible({ timeout: 5000 });
await deleteOption.click();
console.log(' ✅ 已选择删除选项');
await this.page.waitForTimeout(300);
});
When('用户确认删除', async function (this: CustomWorld) {
console.log(' 📍 Step: 确认删除...');
const confirmButton = this.page
.getByRole('dialog')
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
await expect(confirmButton).toBeVisible({ timeout: 5000 });
await confirmButton.click();
console.log(' ✅ 已确认删除');
await this.page.waitForTimeout(500);
});
When('用户在搜索框中输入 {string}', async function (this: CustomWorld, searchText: string) {
console.log(` 📍 Step: 在搜索框中输入 "${searchText}"...`);
// Find the search input in the sidebar
// Support both English and Chinese placeholders
const searchInput = this.page.locator(
'input[placeholder*="Search"], input[placeholder*="搜索"], [data-testid="search-input"]',
);
if ((await searchInput.count()) > 0) {
await searchInput.first().click();
await searchInput.first().fill(searchText);
} else {
// Fallback: click on search icon to reveal search input
const searchIcon = this.page.locator('svg.lucide-search').locator('..');
if ((await searchIcon.count()) > 0) {
await searchIcon.first().click();
await this.page.waitForTimeout(300);
// Now find the input
const input = this.page.locator('input[type="text"]').last();
await input.fill(searchText);
}
}
console.log(` ✅ 已输入搜索内容 "${searchText}"`);
await this.page.waitForTimeout(500);
});
// ============================================
// Then Steps
// ============================================
Then('应该创建一个新的空白对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证新对话已创建...');
// The chat area should be empty or show welcome message
// Check that there are no user/assistant messages
const userMessages = this.page.locator('[data-role="user"]');
const assistantMessages = this.page.locator('[data-role="assistant"]');
const userCount = await userMessages.count();
const assistantCount = await assistantMessages.count();
console.log(` 📍 用户消息数量: ${userCount}, 助手消息数量: ${assistantCount}`);
// New conversation should have no messages
expect(userCount).toBe(0);
expect(assistantCount).toBe(0);
console.log(' ✅ 新对话已创建');
});
Then('页面应该显示欢迎界面', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证页面显示欢迎界面...');
// Wait for the page to update
await this.page.waitForTimeout(500);
// New conversation typically shows a welcome/empty state
// Check for visible chat input (there may be 2 - desktop and mobile, find the visible one)
const chatInputs = this.page.locator('[data-testid="chat-input"]');
const count = await chatInputs.count();
let foundVisible = false;
for (let i = 0; i < count; i++) {
const elem = chatInputs.nth(i);
const box = await elem.boundingBox();
if (box && box.width > 0 && box.height > 0) {
foundVisible = true;
console.log(` 📍 Found visible chat-input at index ${i}`);
break;
}
}
// Just verify the page is loaded properly by checking URL or any content
if (!foundVisible) {
// Fallback: just verify we're still on the chat page
const currentUrl = this.page.url();
expect(currentUrl).toContain('/chat');
console.log(' 📍 Fallback: verified we are on chat page');
}
console.log(' ✅ 欢迎界面已显示');
});
Then('应该切换到该对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证已切换对话...');
// The URL or active state should change
// For now, just verify the page is responsive
await this.page.waitForTimeout(500);
console.log(' ✅ 已切换到该对话');
});
Then('显示该对话的历史消息', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证显示历史消息...');
// Wait for the loading to finish - the messages need time to load after switching topics
console.log(' 📍 等待消息加载...');
await this.page.waitForTimeout(2000);
// Wait for the message wrapper to appear (ChatItem component uses message-wrapper class)
const messageSelector = '.message-wrapper';
try {
await this.page.waitForSelector(messageSelector, { timeout: 10_000 });
} catch {
console.log(' ⚠️ 等待消息选择器超时,尝试备用选择器...');
}
// There should be messages in the chat area
const messages = this.page.locator(messageSelector);
const messageCount = await messages.count();
console.log(` 📍 找到 ${messageCount} 条消息`);
// At least some messages should be visible
expect(messageCount).toBeGreaterThan(0);
console.log(' ✅ 历史消息已显示');
});
Then('对话名称应该更新为 {string}', async function (this: CustomWorld, expectedName: string) {
console.log(` 📍 Step: 验证对话名称为 "${expectedName}"...`);
// Wait for the rename to take effect
await this.page.waitForTimeout(1000);
// Find the topic with the new name by text content
// Topics are in the sidebar, look for text directly
// Use .first() since the name might appear in multiple places (sidebar + favorites section)
const renamedTopic = this.page.getByText(expectedName, { exact: true }).first();
await expect(renamedTopic).toBeVisible({ timeout: 5000 });
console.log(` ✅ 对话名称已更新为 "${expectedName}"`);
});
Then('该对话应该被删除', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证对话已删除...');
// Wait for deletion to take effect
await this.page.waitForTimeout(500);
console.log(' ✅ 对话已删除');
});
Then('对话列表中不再显示该对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证对话列表中不再显示该对话...');
// Wait for UI to update
await this.page.waitForTimeout(500);
// The deleted topic should not be in the list
if (this.testContext.deletedTopicTitle) {
const deletedTopic = this.page.locator(
`[class*="NavItem"]:has-text("${this.testContext.deletedTopicTitle}")`,
);
const count = await deletedTopic.count();
expect(count).toBe(0);
console.log(` ✅ 对话 "${this.testContext.deletedTopicTitle}" 已从列表中移除`);
} else {
console.log(' ✅ 对话已从列表中移除');
}
});
Then('应该显示包含 {string} 的对话', async function (this: CustomWorld, searchText: string) {
console.log(` 📍 Step: 验证搜索结果包含 "${searchText}"...`);
// Wait for search results to load (search opens a modal dialog)
await this.page.waitForTimeout(2000);
// Search results appear in a modal/dialog, not in sidebar
// Look for the search modal and check for matching results
const searchModal = this.page.locator('.ant-modal, [role="dialog"]');
const hasModal = (await searchModal.count()) > 0;
console.log(` 📍 搜索模态框: ${hasModal}`);
// Find matching items in the search results (either in modal or in sidebar if filtered)
const matchingInModal = searchModal.getByText(searchText);
const matchingInPage = this.page.getByText(searchText);
const modalMatchCount = await matchingInModal.count();
const pageMatchCount = await matchingInPage.count();
console.log(` 📍 模态框中找到 ${modalMatchCount} 个匹配, 页面中找到 ${pageMatchCount} 个匹配`);
// At least one match should be found (either in search input or results)
expect(modalMatchCount + pageMatchCount).toBeGreaterThan(0);
console.log(` ✅ 搜索结果显示包含 "${searchText}" 的对话`);
});
Then('不相关的对话应该被过滤', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证不相关对话已被过滤...');
// This would require checking that non-matching topics are hidden
// For now, just verify the search is active
await this.page.waitForTimeout(300);
console.log(' ✅ 不相关对话已被过滤');
});