eigent/test/unit/store/pageTabStore.preview.test.ts
Douglas Lai 8f4f575db9
feat: add native menus, shortcuts, and window chrome (#1858)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:39:55 +08:00

453 lines
15 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 { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/** The preview slice for the currently scoped project. */
function slice() {
return getSessionPreviewSlice(usePageTabStore.getState());
}
describe('pageTabStore session preview', () => {
beforeEach(() => {
window.electronAPI = {
...window.electronAPI,
terminalDispose: vi.fn().mockResolvedValue({ success: true }),
};
usePageTabStore.setState({
sessionPreviewProjectId: null,
sessionPreviewByProject: {},
});
usePageTabStore.getState().setSessionPreviewProject('project-a');
});
it('opens onto a single chooser tab', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
expect(slice().open).toBe(true);
expect(slice().tabs.map((tab) => tab.type)).toEqual(['chooser']);
expect(slice().activeTabId).toBe(slice().tabs[0].id);
});
it('drops mutations when no project is scoped', () => {
const store = usePageTabStore.getState();
store.setSessionPreviewProject(null);
store.toggleSessionPreview();
expect(slice()).toMatchObject({ open: false, tabs: [] });
expect(usePageTabStore.getState().sessionPreviewByProject).toEqual({});
});
it('turns the chooser into the chosen content kind in place', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const chooserId = slice().tabs[0].id;
store.choosePreviewTabType(chooserId, 'browser');
expect(slice().tabs).toHaveLength(1);
const browser = slice().tabs[0];
expect(browser.type).toBe('browser');
expect(slice().activeTabId).toBe(browser.id);
expect(browser.type === 'browser' && browser.webviewId).toContain(
'session-preview:project-a:'
);
// A new chooser (via "+") can become other kinds too.
store.addChooserPreviewTab();
const newChooserId = slice().activeTabId!;
store.choosePreviewTabType(newChooserId, 'canvas');
expect(slice().tabs.map((tab) => tab.type)).toEqual(['browser', 'canvas']);
});
it('opens browser and terminal tabs directly, reusing an existing kind', () => {
const store = usePageTabStore.getState();
store.openPreviewTab('browser');
const browserId = slice().activeTabId;
expect(slice()).toMatchObject({ open: true });
expect(slice().tabs.map((tab) => tab.type)).toEqual(['browser']);
store.closeSessionPreview();
store.openPreviewTab('browser');
expect(slice().activeTabId).toBe(browserId);
expect(slice().tabs).toHaveLength(1);
store.openPreviewTab('terminal');
expect(slice().tabs.map((tab) => tab.type)).toEqual([
'browser',
'terminal',
]);
expect(slice().tabs[1]).toMatchObject({
type: 'terminal',
title: 'Terminal',
});
});
it('exposes every content kind via choosePreviewTabType', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
(['file', 'review', 'terminal', 'canvas'] as const).forEach((kind) => {
store.addChooserPreviewTab();
const id = slice().activeTabId!;
store.choosePreviewTabType(id, kind);
const active = slice().tabs.find((tab) => tab.id === slice().activeTabId);
expect(active?.type).toBe(kind);
});
});
it('gives a fresh terminal tab a project-scoped shell id', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const chooserId = slice().tabs[0].id;
store.choosePreviewTabType(chooserId, 'terminal');
const terminal = slice().tabs[0];
expect(terminal.type).toBe('terminal');
expect(terminal.type === 'terminal' && terminal.shellId).toContain(
'session-shell:project-a:'
);
expect(terminal.type === 'terminal' && terminal.agentSourceId).toBeFalsy();
});
it('opens agent streams in terminal tabs, converting the chooser in place', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const chooserId = slice().tabs[0].id;
store.openAgentTerminalPreview(
'chat-1:turn-1:sub-1',
'Developer Agent',
chooserId
);
expect(slice().tabs).toHaveLength(1);
expect(slice().tabs[0]).toMatchObject({
type: 'terminal',
title: 'Developer Agent',
agentSourceId: 'chat-1:turn-1:sub-1',
});
const firstTabId = slice().tabs[0].id;
// Same stream again — focuses the existing tab instead of duplicating.
store.addChooserPreviewTab();
const secondChooser = slice().activeTabId!;
store.openAgentTerminalPreview(
'chat-1:turn-1:sub-1',
'Developer Agent',
secondChooser
);
expect(slice().activeTabId).toBe(firstTabId);
expect(slice().tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(
1
);
// A different stream without a chooser reference appends a new tab.
store.openAgentTerminalPreview('chat-1:turn-1:sub-2', 'Developer Agent');
expect(slice().tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(
2
);
});
it('does not reuse a read-only agent stream as the interactive terminal', () => {
const store = usePageTabStore.getState();
store.openAgentTerminalPreview('chat-1:turn-1:sub-1', 'Developer Agent');
const agentTabId = slice().activeTabId;
store.openPreviewTab('terminal');
const localTerminal = slice().tabs.find(
(tab) => tab.type === 'terminal' && !tab.agentSourceId
);
expect(slice().tabs).toHaveLength(2);
expect(localTerminal).toMatchObject({
type: 'terminal',
title: 'Terminal',
});
expect(slice().activeTabId).toBe(localTerminal?.id);
expect(slice().activeTabId).not.toBe(agentTabId);
store.closeSessionPreview();
store.openPreviewTab('terminal');
expect(slice().tabs).toHaveLength(2);
expect(slice().activeTabId).toBe(localTerminal?.id);
});
it('reuses the chooser tab when a file is opened', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo;
store.openFilePreview(file);
// Chooser replaced in place — no extra tab piled up.
expect(slice().tabs).toHaveLength(1);
expect(slice().tabs[0]).toMatchObject({
type: 'file',
title: 'doc.txt',
file,
});
});
it('opens and refocuses a Run review without replacing Project review', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const chooserId = slice().activeTabId!;
store.choosePreviewTabType(chooserId, 'review');
const projectReview = slice().tabs[0];
expect(projectReview).toMatchObject({
type: 'review',
reviewTarget: { scope: 'project', focusRequestId: 0 },
});
store.openReviewPreview({ runId: 'run-1', path: './src/app.ts' });
const runReview = slice().tabs.find(
(tab) => tab.type === 'review' && tab.reviewTarget?.scope === 'run'
);
expect(runReview).toMatchObject({
type: 'review',
title: 'Run review',
reviewTarget: {
scope: 'run',
runId: 'run-1',
focusPath: 'src/app.ts',
focusRequestId: 0,
},
});
expect(slice().tabs).toHaveLength(2);
store.openReviewPreview({ runId: 'run-1', path: 'src/other.ts' });
const refocused = slice().tabs.find((tab) => tab.id === runReview?.id);
expect(refocused).toMatchObject({
reviewTarget: {
scope: 'run',
runId: 'run-1',
focusPath: 'src/other.ts',
focusRequestId: 1,
},
});
expect(slice().tabs).toHaveLength(2);
expect(slice().activeTabId).toBe(runReview?.id);
});
it('reuses the empty file tab and deduplicates files by path', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const chooserId = slice().tabs[0].id;
store.choosePreviewTabType(chooserId, 'file');
const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo;
store.openFilePreview(file);
store.openFilePreview({ ...file });
const fileTabs = slice().tabs.filter((tab) => tab.type === 'file');
expect(fileTabs).toHaveLength(1);
expect(fileTabs[0]).toMatchObject({ title: 'doc.txt', file });
expect(slice().activeTabId).toBe(fileTabs[0].id);
});
it('deduplicates pathless Artifacts by durable identity', () => {
const store = usePageTabStore.getState();
const first = {
name: 'first.txt',
type: 'txt',
path: '',
artifactId: 'artifact-1',
localPathAvailable: false,
assetRef: { chatFileId: 1, key: 'first.txt' },
} as FileInfo;
const second = {
name: 'second.txt',
type: 'txt',
path: '',
artifactId: 'artifact-2',
localPathAvailable: false,
assetRef: { chatFileId: 2, key: 'second.txt' },
} as FileInfo;
store.openFilePreview(first);
store.openFilePreview(second);
store.openFilePreview({ ...first });
const fileTabs = slice().tabs.filter((tab) => tab.type === 'file');
expect(fileTabs).toHaveLength(2);
expect(fileTabs.map((tab) => tab.title)).toEqual([
'first.txt',
'second.txt',
]);
expect(slice().activeTabId).toBe(fileTabs[0].id);
});
it('refreshes a deduplicated Artifact tab when its preview becomes local', () => {
const store = usePageTabStore.getState();
const remote = {
name: 'index.html',
type: 'html',
path: 'https://example.test/signed/index.html',
relativePath: 'P5/index.html',
artifactId: 'artifact-html',
localPathAvailable: false,
isRemote: true,
assetRef: { chatFileId: 3, key: 'P5/index.html' },
} as FileInfo;
const local = {
...remote,
path: '/workspace/space-1/P5/index.html',
localPathAvailable: true,
isRemote: false,
} as FileInfo;
store.openFilePreview(remote);
const originalTab = slice().tabs[0];
store.openFilePreview(local);
expect(slice().tabs).toHaveLength(1);
expect(slice().tabs[0]).toMatchObject({
id: originalTab.id,
type: 'file',
title: 'index.html',
file: local,
});
});
it('opens chat links in a browser tab of the current project preview', () => {
const store = usePageTabStore.getState();
store.openBrowserPreview('https://example.com/docs');
expect(slice().open).toBe(true);
expect(slice().tabs).toHaveLength(1);
expect(slice().tabs[0]).toMatchObject({
type: 'browser',
url: 'https://example.com/docs',
title: 'example.com',
});
expect(slice().activeTabId).toBe(slice().tabs[0].id);
// Invalid destinations are ignored.
store.openBrowserPreview('javascript:alert(1)');
expect(slice().tabs).toHaveLength(1);
});
it('reuses the chooser and deduplicates browser tabs by URL', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
store.openBrowserPreview('https://example.com/docs');
// Chooser replaced in place — no extra tab piled up.
expect(slice().tabs).toHaveLength(1);
expect(slice().tabs[0].type).toBe('browser');
// The same URL (modulo trailing slash) focuses the existing tab…
store.openBrowserPreview('https://example.com/docs/');
expect(slice().tabs).toHaveLength(1);
// …while a different URL opens alongside it.
store.openBrowserPreview('https://other.example.com');
expect(slice().tabs).toHaveLength(2);
expect(slice().activeTabId).toBe(slice().tabs[1].id);
});
it('selects a neighboring tab and closes the panel after the final tab', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const chooserId = slice().tabs[0].id;
store.choosePreviewTabType(chooserId, 'browser');
store.addChooserPreviewTab();
store.choosePreviewTabType(slice().activeTabId!, 'file');
const [browserTab, fileTab] = slice().tabs;
store.closeSessionPreviewTab(browserTab.id);
expect(slice().activeTabId).toBe(fileTab.id);
store.closeSessionPreviewTab(fileTab.id);
expect(slice()).toMatchObject({
open: false,
tabs: [],
activeTabId: null,
});
});
it('closes without discarding tabs and resets project-scoped state', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
store.closeSessionPreview();
expect(slice().open).toBe(false);
expect(slice().tabs).toHaveLength(1);
store.resetSessionPreview();
expect(slice()).toMatchObject({
open: false,
tabs: [],
activeTabId: null,
});
});
it('keeps preview state per project and restores it on switch-back', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const projectATabs = slice().tabs;
expect(projectATabs).toHaveLength(1);
// Switching projects swaps in the other project's (empty) slice…
store.setSessionPreviewProject('project-b');
expect(slice()).toMatchObject({
open: false,
tabs: [],
activeTabId: null,
});
// …and switching back restores tabs, active tab, and the open flag.
store.setSessionPreviewProject('project-a');
expect(slice().open).toBe(true);
expect(slice().tabs).toEqual(projectATabs);
expect(slice().activeTabId).toBe(projectATabs[0].id);
});
it('records mutations into the per-project slice for persistence', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
const file = { name: 'doc.txt', path: '/tmp/doc.txt' } as FileInfo;
store.openFilePreview(file);
const persisted =
usePageTabStore.getState().sessionPreviewByProject['project-a'];
expect(persisted.open).toBe(true);
expect(
persisted.tabs.filter((tab) => tab.type === 'file' && tab.file !== null)
).toHaveLength(1);
expect(persisted.activeTabId).toBe(slice().activeTabId);
});
it('disposes shells and drops persisted preview state with a project', () => {
const store = usePageTabStore.getState();
store.toggleSessionPreview();
store.choosePreviewTabType(slice().activeTabId!, 'terminal');
const terminal = slice().tabs[0];
expect(terminal.type).toBe('terminal');
const shellId = terminal.type === 'terminal' ? terminal.shellId : undefined;
store.setSessionPreviewProject('project-b');
store.toggleSessionPreview();
store.removeSessionPreviewProject('project-a');
expect(window.electronAPI.terminalDispose).toHaveBeenCalledWith(shellId);
expect(
usePageTabStore.getState().sessionPreviewByProject['project-a']
).toBeUndefined();
expect(usePageTabStore.getState().sessionPreviewProjectId).toBe(
'project-b'
);
expect(slice().open).toBe(true);
});
});