qwen-code/packages/web-shell/client/components/SplitView.test.tsx
ytahdn b2b02d27ff
feat(web-shell): expose external split controls (#6523)
* feat(web-shell): expose external split controls

* fix(web-shell): tighten split controlled behavior

* fix(web-shell): address split control review

* fix(web-shell): sync controlled split exit

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
2026-07-08 12:02:10 +00:00

408 lines
14 KiB
TypeScript

// @vitest-environment jsdom
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
/* eslint-disable @typescript-eslint/no-explicit-any */
let connectionState: any;
let sessionsState: any[];
// Stable across renders (assigned once per test) so SplitView's reload effects,
// which depend on `reload`'s identity, don't re-fire on every render.
let reloadMock: ReturnType<typeof vi.fn>;
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DaemonSessionProvider: (props: any) => (
<div data-session={props.sessionId} data-clientid={props.clientId}>
{props.children}
</div>
),
useConnection: () => connectionState,
// Stateful, like the real hook: reload() re-renders with the CURRENT module
// store. This lets a test prove the picker renders sessions that appeared
// only after the reload — not merely that reload() was called.
useSessions: () => {
const [sessions, setSessions] = React.useState<any[]>(() => sessionsState);
const reload = React.useCallback(async () => {
reloadMock();
setSessions([...sessionsState]);
return sessionsState;
}, []);
return { sessions, reload };
},
}));
vi.mock('./ChatPane', () => ({
ChatPane: (props: any) => {
// Let a test force a render crash to exercise the per-pane ErrorBoundary.
if (props.title === 'BOOM') throw new Error('pane exploded');
return (
<div data-testid="chat-pane">
<span data-testid="pane-title">{props.title}</span>
{props.onClose && (
<button data-testid="pane-close" onClick={props.onClose}>
x
</button>
)}
</div>
);
},
}));
const { SplitView } = await import('./SplitView');
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
connectionState = {
sessionId: 's3',
capabilities: { features: [] },
workspaceCwd: '/w',
};
sessionsState = [
{ sessionId: 's1', workspaceCwd: '/w', displayName: 'One' },
{ sessionId: 's2', workspaceCwd: '/w', displayName: 'Two' },
{ sessionId: 's3', workspaceCwd: '/w', displayName: 'Three' },
{ sessionId: 's4', workspaceCwd: '/w', displayName: 'Four' },
];
reloadMock = vi.fn();
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function render(props: Record<string, unknown> = {}): void {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} {...props} />
</I18nProvider>,
),
);
}
function panes(): HTMLElement[] {
return Array.from(container!.querySelectorAll('[data-testid="chat-pane"]'));
}
function titles(): string[] {
return Array.from(
container!.querySelectorAll('[data-testid="pane-title"]'),
).map((el) => el.textContent ?? '');
}
function pickerOptions(): string[] {
return Array.from(container!.querySelectorAll('[role="option"] button')).map(
(el) => (el.textContent ?? '').trim(),
);
}
function openPicker(): void {
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
}
describe('SplitView', () => {
it('renders one pane per initial session, each under its own provider', () => {
render({ sessionIds: ['s1', 's2'] });
expect(panes()).toHaveLength(2);
expect(titles()).toEqual(['One', 'Two']);
const providers = container!.querySelectorAll('[data-session]');
expect(providers[0].getAttribute('data-session')).toBe('s1');
// Panes use a distinct client id (with a per-mount nonce) so they don't
// collide with the main view — or with another tab's panes for the session.
const clientId = providers[0].getAttribute('data-clientid') ?? '';
expect(clientId).toMatch(/^split-pane:.+:s1$/);
// Both panes share this instance's nonce.
const s2ClientId = providers[1].getAttribute('data-clientid') ?? '';
const nonce = clientId.slice('split-pane:'.length, -':s1'.length);
expect(s2ClientId).toBe(`split-pane:${nonce}:s2`);
});
it('seeds with the current session when no session ids are given', () => {
render();
expect(titles()).toEqual(['Three']);
});
it('dedupes initial sessions', () => {
render({ sessionIds: ['s1', 's1', 's2'] });
expect(titles()).toEqual(['One', 'Two']);
});
it('syncs panes when session ids change after mount', () => {
render({ sessionIds: ['s1'] });
expect(titles()).toEqual(['One']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} sessionIds={['s1', 's2']} />
</I18nProvider>,
),
);
expect(titles()).toEqual(['One', 'Two']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} sessionIds={[]} />
</I18nProvider>,
),
);
expect(panes()).toHaveLength(0);
});
it('requests pane changes without mutating local panes when controlled', () => {
const onPanesChange = vi.fn();
render({ sessionIds: ['s1'], onPanesChange });
openPicker();
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(onPanesChange).toHaveBeenCalledWith(['s1', 's2']);
expect(titles()).toEqual(['One']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView
onExit={() => {}}
sessionIds={['s1', 's2']}
onPanesChange={onPanesChange}
/>
</I18nProvider>,
),
);
expect(titles()).toEqual(['One', 'Two']);
const close = container!.querySelector('[data-testid="pane-close"]');
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onPanesChange).toHaveBeenCalledWith(['s2']);
expect(titles()).toEqual(['One', 'Two']);
});
it('adds a pane from the picker', () => {
render();
expect(panes()).toHaveLength(1);
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// Picker lists sessions not already shown (s1, s2, s4).
const options = container!.querySelectorAll('[role="option"] button');
expect(options).toHaveLength(3);
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(panes()).toHaveLength(2);
});
it('closes the picker on Escape', () => {
render({ sessionIds: ['s1'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(addButton.getAttribute('aria-expanded')).toBe('true');
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
expect(addButton.getAttribute('aria-expanded')).toBe('false');
expect(container!.querySelector('[role="listbox"]')).toBeNull();
});
it('closes the picker on a click outside it', () => {
render({ sessionIds: ['s1'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(container!.querySelector('[role="listbox"]')).not.toBeNull();
// A mousedown anywhere outside the add-wrap dismisses it…
act(() =>
document.body.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true }),
),
);
expect(addButton.getAttribute('aria-expanded')).toBe('false');
expect(container!.querySelector('[role="listbox"]')).toBeNull();
});
it('keeps the picker open on a click inside it', () => {
render({ sessionIds: ['s1'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
const listbox = container!.querySelector('[role="listbox"]') as HTMLElement;
// A mousedown on the picker itself must not dismiss it (the click that
// selects an option would otherwise be swallowed).
act(() =>
listbox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })),
);
expect(addButton.getAttribute('aria-expanded')).toBe('true');
expect(container!.querySelector('[role="listbox"]')).not.toBeNull();
});
it('removes a pane via its close button', () => {
render();
openPicker();
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(panes()).toHaveLength(2);
const closes = container!.querySelectorAll('[data-testid="pane-close"]');
act(() =>
closes[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(titles()).toEqual(['One']);
});
it('auto-exits to the overview when the last pane is closed', () => {
const onExit = vi.fn();
render({ onExit });
expect(onExit).not.toHaveBeenCalled();
const close = container!.querySelector('[data-testid="pane-close"]');
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onExit).toHaveBeenCalledTimes(1);
});
it('exits via the back button', () => {
const onExit = vi.fn();
render({ sessionIds: ['s1'], onExit });
// The back button is the first toolbar button (aria-label from common.back).
const back = container!.querySelector('header button') as HTMLButtonElement;
act(() => back.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onExit).toHaveBeenCalledTimes(1);
});
it('caps the number of panes at MAX_PANES (6)', () => {
sessionsState = Array.from({ length: 8 }, (_, i) => ({
sessionId: `x${i}`,
workspaceCwd: '/w',
displayName: `Pane ${i}`,
}));
render({ sessionIds: sessionsState.map((s) => s.sessionId) });
// Eight requested, but only six live panes mount.
expect(panes()).toHaveLength(6);
});
it('isolates a crashing pane so the rest of the split survives', () => {
sessionsState = [
{ sessionId: 's1', workspaceCwd: '/w', displayName: 'BOOM' },
{ sessionId: 's2', workspaceCwd: '/w', displayName: 'Two' },
];
render({ sessionIds: ['s1', 's2'] });
// The crashing pane shows its error fallback; the healthy pane still renders.
expect(container!.textContent).toContain('This session pane hit an error');
expect(panes()).toHaveLength(1);
expect(titles()).toEqual(['Two']);
});
it('reloads the session list when the picker opens (never a stale list)', () => {
render({ sessionIds: ['s1'] });
// `useSessions` only fetches on mount; nothing reloads until the user acts.
expect(reloadMock).not.toHaveBeenCalled();
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('renders the refreshed session list on reopen — not the entry snapshot', () => {
render({ sessionIds: ['s1'] });
// First open: the picker offers the sessions present at entry.
openPicker();
expect(pickerOptions()).toEqual(['Two', 'Three', 'Four']);
// A session is created elsewhere after the split was entered…
sessionsState = [
...sessionsState,
{ sessionId: 's5', workspaceCwd: '/w', displayName: 'Five' },
];
// …reopening the picker reloads and the new session now appears. Without the
// reload-on-open the list would be frozen at the entry snapshot (no 'Five').
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
openPicker();
expect(pickerOptions()).toEqual(['Two', 'Three', 'Four', 'Five']);
});
it('reloads the picker list when the parent bumps the reload token', () => {
render({ sessionIds: ['s1'], sessionListReloadToken: 0 });
// The initial token is not a change, so it does not trigger a reload.
expect(reloadMock).not.toHaveBeenCalled();
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView
onExit={() => {}}
sessionIds={['s1']}
sessionListReloadToken={1}
/>
</I18nProvider>,
),
);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('mirrors the live pane set up to the parent as panes change', () => {
const onPanesChange = vi.fn();
render({ onPanesChange });
// Reported on mount so the parent's seed reflects the actual panes…
expect(onPanesChange).toHaveBeenLastCalledWith(['s3']);
// …and after every add (so switching away and back restores it).
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(onPanesChange).toHaveBeenLastCalledWith(['s3', 's1']);
// …and after every remove.
const close = container!.querySelector('[data-testid="pane-close"]');
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onPanesChange).toHaveBeenLastCalledWith(['s1']);
});
});