// ========= 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 { PreviewPanel } from '@/components/Session/PreviewPanel';
import {
registerPreviewWebview,
unregisterPreviewWebview,
} from '@/components/Session/PreviewPanel/tabs/browser/webviewRegistry';
import { HostProvider } from '@/host';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/components/Folder/FilePreview', () => ({
FilePreview: ({ file }: { file: FileInfo | null }) => (
{file?.name || 'No file selected'}
),
}));
// React Flow needs layout APIs jsdom lacks; the canvas tab is exercised
// elsewhere, so stub it to keep this suite focused on the router/tab strip.
vi.mock('@/components/Session/PreviewPanel/tabs/CanvasTab', () => ({
CanvasTab: () => ,
}));
// xterm needs real layout/canvas APIs; the terminal tab has its own suite.
vi.mock('@/components/Session/PreviewPanel/tabs/terminal/TerminalTab', () => ({
TerminalTab: () => ,
}));
// Monaco and the live project stores are covered by the ReviewTab suite.
vi.mock('@/components/Session/PreviewPanel/tabs/ReviewTab', () => ({
ReviewTab: () => ,
}));
// The chooser lists the project's agent terminal streams via this hook; keep
// the suite off the chat-store dependency chain and drive it with fixtures.
let mockTerminalSources: Array<{
id: string;
agentName: string;
taskLabel: string;
lines: string[];
status: 'running' | 'idle';
}> = [];
vi.mock(
'@/components/Session/PreviewPanel/tabs/terminal/useSessionTerminalSources',
() => ({
useSessionTerminalSources: () => mockTerminalSources,
})
);
// The desktop host is detected by electronAPI presence; embedded browsing
// itself is -tag based and driven through the webview registry.
const openExternal = vi.fn();
const host = { ipcRenderer: null, electronAPI: { openExternal } };
function renderPanel() {
return render(
);
}
function previewSlice() {
return getSessionPreviewSlice(usePageTabStore.getState());
}
function activeType() {
const slice = previewSlice();
return slice.tabs.find((tab) => tab.id === slice.activeTabId)?.type;
}
describe('PreviewPanel', () => {
beforeEach(() => {
mockTerminalSources = [];
openExternal.mockReset();
openExternal.mockResolvedValue({ success: true });
usePageTabStore.setState({
sessionPreviewProjectId: null,
sessionPreviewByProject: {},
previewBrowserViewport: null,
});
usePageTabStore.getState().setSessionPreviewProject('project-test');
usePageTabStore.getState().toggleSessionPreview();
});
it('opens on the chooser tab listing the available content kinds', () => {
renderPanel();
expect(screen.getByRole('tab', { name: 'New tab' })).toBeInTheDocument();
// Vertical options use the same product copy users see in the chooser.
for (const label of ['Browser', 'Files', 'Review', 'Terminal']) {
expect(
screen.getByRole('button', {
name: new RegExp(`^${label}\\b`),
})
).toBeInTheDocument();
}
// Reserved kinds stay hidden from the chooser until a later version.
for (const label of ['Canvas']) {
expect(
screen.queryByRole('button', {
name: new RegExp(`^${label}\\b`),
})
).not.toBeInTheDocument();
}
});
it('lists the project’s agent streams in the chooser and opens one in place', async () => {
const user = userEvent.setup();
mockTerminalSources = [
{
id: 'chat-1:turn-1:sub-1',
agentName: 'Developer Agent',
taskLabel: 'Start dev server',
lines: ['npm run dev'],
status: 'running',
},
];
renderPanel();
expect(screen.getByText('From this project')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Start dev server/ }));
const slice = previewSlice();
const active = slice.tabs.find((tab) => tab.id === slice.activeTabId);
expect(active).toMatchObject({
type: 'terminal',
title: 'Developer Agent',
agentSourceId: 'chat-1:turn-1:sub-1',
});
// The chooser was converted in place, not left behind.
expect(slice.tabs).toHaveLength(1);
expect(screen.getByTestId('terminal-tab')).toBeInTheDocument();
});
it('picking a chooser option turns the tab into that content kind', async () => {
const user = userEvent.setup();
renderPanel();
await user.click(screen.getByRole('button', { name: /^Browser\b/ }));
expect(activeType()).toBe('browser');
// Address bar of the browser tab is now shown.
expect(
screen.getByRole('textbox', { name: 'Enter a URL' })
).toBeInTheDocument();
});
it('routes to the file tab and reuses its tab by path', () => {
const file = { name: 'notes.md', path: '/tmp/notes.md' } as FileInfo;
usePageTabStore.getState().openFilePreview(file);
usePageTabStore.getState().openFilePreview({ ...file });
renderPanel();
expect(screen.getByTestId('file-preview')).toHaveTextContent('notes.md');
expect(screen.getAllByRole('tab', { name: 'notes.md' })).toHaveLength(1);
});
it('routes review, terminal, and canvas tabs to their surfaces', () => {
const store = usePageTabStore.getState();
const chooserId = previewSlice().tabs[0].id;
act(() => store.choosePreviewTabType(chooserId, 'review'));
const { rerender } = renderPanel();
expect(screen.getByTestId('review-tab')).toBeInTheDocument();
act(() =>
store.choosePreviewTabType(previewSlice().activeTabId!, 'terminal')
);
rerender(
);
expect(screen.getByTestId('terminal-tab')).toBeInTheDocument();
act(() =>
store.choosePreviewTabType(previewSlice().activeTabId!, 'canvas')
);
rerender(
);
expect(screen.getByTestId('canvas-tab')).toBeInTheDocument();
});
it('the + button adds a new chooser tab', async () => {
const user = userEvent.setup();
renderPanel();
await user.click(screen.getByRole('button', { name: 'New tab' }));
expect(screen.getAllByRole('tab', { name: 'New tab' })).toHaveLength(2);
expect(activeType()).toBe('chooser');
});
it('drives back/forward/reload on the registered guest element', async () => {
const user = userEvent.setup();
const store = usePageTabStore.getState();
const chooserId = previewSlice().tabs[0].id;
act(() => store.choosePreviewTabType(chooserId, 'browser'));
const browserTab = previewSlice().tabs.find(
(tab) => tab.type === 'browser'
)!;
act(() =>
store.updateBrowserPreviewTab(browserTab.id, {
url: 'https://example.com/a',
navigation: {
url: 'https://example.com/a',
title: 'A',
isLoading: false,
canGoBack: true,
canGoForward: true,
},
})
);
const goBack = vi.fn();
const goForward = vi.fn();
const reload = vi.fn();
registerPreviewWebview(browserTab.webviewId, {
goBack,
goForward,
reload,
} as unknown as HTMLElement & { goBack: typeof goBack });
try {
renderPanel();
await user.click(screen.getByRole('button', { name: 'Back' }));
await user.click(screen.getByRole('button', { name: 'Forward' }));
await user.click(screen.getByRole('button', { name: 'Reload' }));
expect(goBack).toHaveBeenCalled();
expect(goForward).toHaveBeenCalled();
expect(reload).toHaveBeenCalled();
} finally {
unregisterPreviewWebview(browserTab.webviewId);
}
});
it('opens desktop external links through the Electron external IPC', async () => {
const user = userEvent.setup();
const store = usePageTabStore.getState();
const chooserId = previewSlice().tabs[0].id;
act(() => store.choosePreviewTabType(chooserId, 'browser'));
const browserTab = previewSlice().tabs.find(
(tab) => tab.type === 'browser'
)!;
act(() =>
store.updateBrowserPreviewTab(browserTab.id, {
url: 'http://localhost:3000/',
})
);
renderPanel();
await user.click(screen.getByRole('button', { name: 'Open externally' }));
expect(openExternal).toHaveBeenCalledWith('http://localhost:3000/');
});
it('closing the final tab closes the panel', async () => {
const user = userEvent.setup();
renderPanel();
await user.click(screen.getByRole('button', { name: 'Close tab' }));
expect(previewSlice()).toMatchObject({
open: false,
tabs: [],
activeTabId: null,
});
});
});