eigent/test/unit/components/Session/SessionResize.test.tsx
2026-08-21 22:58:16 +08:00

291 lines
8.3 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 Session from '@/components/Session';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
chatBoxRenderCount: 0,
pageState: {
activeWorkspaceTab: 'project',
setActiveWorkspaceTab: vi.fn(),
closeSessionPreview: vi.fn(),
setSessionPreviewProject: vi.fn(),
sessionPreviewProjectId: 'project-1',
sessionSidePanelToggleRequestId: 0,
previewSlice: { open: true, tabs: [], activeTabId: null },
},
projectState: {
activeProjectId: 'project-1',
getAllChatStores: vi.fn(() => []),
},
chatState: {
activeTaskId: 'task-1',
tasks: {
'task-1': {
status: 'finished',
messages: [{ id: 'message-1', role: 'user', content: 'hello' }],
hasMessages: true,
tokens: 0,
},
},
setSelectedFile: vi.fn(),
},
}));
vi.mock('@/components/ChatBox', () => ({
default: () => {
mocks.chatBoxRenderCount += 1;
return <div data-testid="chat-box" />;
},
}));
vi.mock('@/components/Workspace', () => ({
default: () => <div data-testid="workspace" />,
}));
vi.mock('@/components/Session/HeaderBox', () => ({
HeaderBox: () => <div data-testid="session-header" />,
}));
vi.mock('@/components/Session/PreviewPanel', () => ({
PreviewPanel: () => <div data-testid="preview-panel" />,
}));
vi.mock('@/components/Session/SidePanel', () => ({
SessionSidePanel: () => <div data-testid="session-side-panel-content" />,
}));
vi.mock('@/hooks/useChatStoreAdapter', () => ({
default: () => ({
chatStore: mocks.chatState,
projectStore: mocks.projectState,
}),
}));
vi.mock('@/hooks/useProjectEventRuntime', () => ({
ProjectEventRuntimeProvider: ({ children }: { children: React.ReactNode }) =>
children,
}));
vi.mock('@/lib/sessionMode', () => ({
inferSessionModeFromTask: () => 'single_agent',
}));
vi.mock('@/store/pageTabStore', () => ({
getSessionPreviewSlice: (state: typeof mocks.pageState) => state.previewSlice,
usePageTabStore: (selector: (state: typeof mocks.pageState) => unknown) =>
selector(mocks.pageState),
WorkspaceTab: { Project: 'project' },
}));
vi.mock('@/store/projectRuntimeStore', () => ({
useProjectRuntimeStore: (
selector: (state: {
historyLoadingProjectIds: Record<string, boolean>;
}) => unknown
) => selector({ historyLoadingProjectIds: {} }),
}));
vi.mock('@/store/spaceStore', () => ({
useSpaceStore: (
selector: (state: {
getProjectMeta: () => { name: string; mode: string; metadata: object };
updateProjectMeta: ReturnType<typeof vi.fn>;
}) => unknown
) =>
selector({
getProjectMeta: () => ({
name: 'Project 1',
mode: 'single_agent',
metadata: {},
}),
updateProjectMeta: vi.fn(),
}),
}));
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion: {
div: ({
children,
initial: _initial,
animate: _animate,
exit: _exit,
transition: _transition,
layout: _layout,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
initial?: unknown;
animate?: unknown;
exit?: unknown;
transition?: unknown;
layout?: unknown;
}) => <div {...props}>{children}</div>,
},
}));
let scheduledFrame: FrameRequestCallback | null = null;
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
function rect(width: number): DOMRect {
return {
x: 0,
y: 0,
top: 0,
right: width,
bottom: 800,
left: 0,
width,
height: 800,
toJSON: () => ({}),
};
}
async function renderResizableSession() {
render(<Session />);
const separator = screen.getByRole('separator');
const row = separator.parentElement?.parentElement;
if (!row) throw new Error('missing Session row');
await waitFor(() =>
expect(row.style.getPropertyValue('--session-chat-width')).toBe('360px')
);
Object.defineProperties(separator, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: {
configurable: true,
value: vi.fn(() => true),
},
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(separator, {
button: 0,
buttons: 1,
clientX: 360,
isPrimary: true,
pointerId: 7,
});
await waitFor(() =>
expect(separator).toHaveAttribute('data-resize-handle-state', 'drag')
);
return { row, separator };
}
describe('Session preview resize', () => {
beforeEach(() => {
vi.stubGlobal('PointerEvent', TestPointerEvent);
mocks.chatBoxRenderCount = 0;
mocks.pageState.previewSlice.open = true;
mocks.pageState.sessionPreviewProjectId = 'project-1';
mocks.pageState.activeWorkspaceTab = 'project';
scheduledFrame = null;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function () {
return rect(this.id === 'session-side-panel' ? 0 : 1200);
}
);
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduledFrame = callback;
return 1;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {
scheduledFrame = null;
});
});
afterAll(() => {
vi.unstubAllGlobals();
});
it('coalesces pointer moves and ends a lost pointerup when no button is pressed', async () => {
const { row, separator } = await renderResizableSession();
const rendersAfterStart = mocks.chatBoxRenderCount;
fireEvent.pointerMove(window, {
buttons: 1,
clientX: 430,
pointerId: 7,
});
fireEvent.pointerMove(window, {
buttons: 1,
clientX: 500,
pointerId: 7,
});
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(mocks.chatBoxRenderCount).toBe(rendersAfterStart);
expect(screen.getByTestId('chat-box')).toBeInTheDocument();
expect(
document.querySelector('[data-preview-resize-shield]')
).not.toBeNull();
act(() => {
const frame = scheduledFrame;
scheduledFrame = null;
frame?.(0);
});
expect(row.style.getPropertyValue('--session-chat-width')).toBe('500px');
fireEvent.pointerMove(window, {
buttons: 0,
clientX: 500,
pointerId: 7,
});
await waitFor(() =>
expect(separator).toHaveAttribute('data-resize-handle-state', 'inactive')
);
expect(document.querySelector('[data-preview-resize-shield]')).toBeNull();
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
expect(separator.releasePointerCapture).toHaveBeenCalledWith(7);
});
it.each([
[
'pointer cancellation',
(_separator: HTMLElement) =>
fireEvent.pointerCancel(window, { pointerId: 7 }),
],
[
'capture loss',
(separator: HTMLElement) =>
separator.dispatchEvent(new Event('lostpointercapture')),
],
[
'window blur',
(_separator: HTMLElement) => window.dispatchEvent(new Event('blur')),
],
])('ends resize on %s', async (_label, endDrag) => {
const { separator } = await renderResizableSession();
act(() => endDrag(separator));
await waitFor(() =>
expect(separator).toHaveAttribute('data-resize-handle-state', 'inactive')
);
expect(document.querySelector('[data-preview-resize-shield]')).toBeNull();
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
});
});