Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr-assets/search-panel-v056-refresh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 71 additions & 26 deletions src/__tests__/e2e/global-search-modes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ async function createSession(page: Page, title: string, workingDirectory: string
}

test.describe('Global Search modes UX', () => {
test.setTimeout(60_000);

test('supports all/session/message/file modes and keyboard open', async ({ page }) => {
const searchResultTimeout = 20_000;
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const rootA = path.join(os.tmpdir(), `codepilot-search-modes-a-${suffix}`);
const rootB = path.join(os.tmpdir(), `codepilot-search-modes-b-${suffix}`);
Expand All @@ -44,6 +47,7 @@ test.describe('Global Search modes UX', () => {
const sessionTitleB = `Search Session Beta ${suffix}`;
const messageTokenA = `message-token-alpha-${suffix}`;
const messageTokenB = `message-token-beta-${suffix}`;
const searchDialog = page.locator('[data-slot="dialog-content"]').first();

await fs.mkdir(path.dirname(filePathA), { recursive: true });
await fs.mkdir(rootB, { recursive: true });
Expand All @@ -54,45 +58,86 @@ test.describe('Global Search modes UX', () => {
addMessage(sessionA, 'user', `User says ${messageTokenA}`);
addMessage(sessionB, 'assistant', `Assistant says ${messageTokenB}`);

const searchInput = page.locator(
'input[data-slot="command-input"], input[placeholder*="Search"], input[placeholder*="搜索"]'
).first();
const searchInput = page.locator('input[data-slot="command-input"]').first();
const searchSurface = page.getByTestId('global-search-surface');
const openSearch = async () => {
await expect(page.getByRole('button', { name: /^(搜索|Search)$/ }).first()).toBeVisible({
timeout: 10_000,
});
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('open-global-search'));
});
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await expect(searchSurface).toBeVisible({ timeout: 10_000 });
};
const getSearchDialogGeometry = async () => {
const box = await searchDialog.boundingBox();
expect(box).toBeTruthy();
return {
top: Math.round(box!.y),
height: Math.round(box!.height),
};
};

try {
await page.goto(`/chat/${sessionA}`);

// Open global search from the sidebar trigger (language-agnostic fallback).
await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click();
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await openSearch();
const emptyGeometry = await getSearchDialogGeometry();

// Default all-mode caps its file-branch scan at the N most-recent
// workspaces (see ALL_MODE_FILE_SESSION_LIMIT in /api/search) so the
// POST stays under ~1s even on a populated DB — the earlier 30s
// timeout was masking the unbounded-scan latency that Codex flagged.
// All-mode returns all three result types.
await searchInput.fill(suffix);
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
await expect(page.getByText(fileNameA).first()).toBeVisible();
await expect(page.getByText(messageTokenA).first()).toBeVisible();
await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: searchResultTimeout });
await expect(page.getByTestId('global-search-section-files')).toBeVisible({ timeout: searchResultTimeout });
await expect(page.getByTestId('global-search-section-messages')).toBeVisible({ timeout: searchResultTimeout });
await expect(
page.getByTestId('global-search-section-sessions').getByTestId('global-search-item')
).toHaveCount(2, { timeout: searchResultTimeout });
await expect(
page.getByTestId('global-search-section-files').getByTestId('global-search-item')
).toHaveCount(1, { timeout: searchResultTimeout });
await expect(
page.getByTestId('global-search-section-messages').getByTestId('global-search-item')
).toHaveCount(2, { timeout: searchResultTimeout });
const resultGeometry = await getSearchDialogGeometry();
expect(Math.abs(resultGeometry.top - emptyGeometry.top)).toBeLessThanOrEqual(1);
expect(Math.abs(resultGeometry.height - emptyGeometry.height)).toBeLessThanOrEqual(2);

// session: prefix narrows to session result.
await searchInput.fill(`session:${sessionTitleA}`);
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
await expect(page.getByText(fileNameA)).toHaveCount(0);
// Clicking a scope chip rewrites the prefix and narrows the result set.
await page.getByTestId('global-search-scope-sessions').click();
await expect(searchInput).toHaveValue(`session:${suffix}`);
await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: searchResultTimeout });
await expect(
page.getByTestId('global-search-section-sessions').getByTestId('global-search-item')
).toHaveCount(2, { timeout: searchResultTimeout });
await expect(page.getByTestId('global-search-section-files')).toHaveCount(0);

// message: prefix narrows to message snippets and supports navigation to target session.
await searchInput.fill(`message:${messageTokenB}`);
await expect(page.getByText(messageTokenB)).toBeVisible({ timeout: 10_000 });
await page.getByText(messageTokenB).first().click();
await expect(page.getByTestId('global-search-scope-messages')).toHaveAttribute('aria-pressed', 'true');
await expect(
page.getByTestId('global-search-section-messages').getByTestId('global-search-item')
).toHaveCount(1, { timeout: searchResultTimeout });
await expect(
page.getByTestId('global-search-section-messages').getByText(messageTokenB).first()
).toBeVisible({ timeout: searchResultTimeout });
await page.getByTestId('global-search-section-messages').getByText(messageTokenB).first().click();
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 });

// Re-open and verify file: prefix still works in the same UX flow.
await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click();
await expect(searchInput).toBeVisible({ timeout: 10_000 });
// Re-open and verify file scope still works in the same UX flow.
await openSearch();
await searchInput.fill('');
await page.getByTestId('global-search-scope-files').click();
await expect(searchInput).toHaveValue('file:');
await searchInput.fill(`file:${fileNameA}`);
await expect(page.getByText(/(Searching in|当前搜索范围)/)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('file:')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(fileNameA)).toBeVisible({ timeout: 10_000 });
await page.getByText(fileNameA).first().click();
await expect(page.getByTestId('global-search-scope-files')).toHaveAttribute('aria-pressed', 'true');
await expect(
page.getByTestId('global-search-section-files').getByTestId('global-search-item')
).toHaveCount(1, { timeout: searchResultTimeout });
await expect(
page.getByTestId('global-search-section-files').getByText(fileNameA).first()
).toBeVisible({ timeout: searchResultTimeout });
await page.getByTestId('global-search-section-files').getByText(fileNameA).first().click();
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 });
} finally {
await page.request.delete(`/api/chat/sessions/${sessionA}`, { timeout: 5_000 }).catch(() => {});
Expand Down
Loading