// @vitest-environment jsdom /** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonSessionSummary, DaemonStatusReportSession, } from '@qwen-code/sdk/daemon'; import { I18nProvider } from '../i18n'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); // --- Mutable mock state, reset in beforeEach --- let connectionState: { sessionId?: string; capabilities?: { features?: string[] }; workspaceCwd?: string; }; let sessionsState: { sessions: DaemonSessionSummary[]; loading: boolean; error?: Error; }; let statusState: { report?: { full?: { sessions: DaemonStatusReportSession[] } }; }; const sessionsReload = vi.fn(async () => sessionsState.sessions); const statusReload = vi.fn(async () => statusState.report); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useConnection: () => connectionState, useSessions: () => ({ ...sessionsState, reload: sessionsReload }), useStatusReport: () => ({ ...statusState, reload: statusReload }), })); const { SessionOverviewPanel, deriveSessionCards } = await import( './SessionOverviewPanel' ); function session( id: string, extra: Partial = {}, ): DaemonSessionSummary { return { sessionId: id, workspaceCwd: '/w', updatedAt: '2026-07-06T10:00:00.000Z', ...extra, }; } function statusSession( id: string, extra: Partial = {}, ): DaemonStatusReportSession { return { sessionId: id, workspaceCwd: '/w', createdAt: '2026-07-06T09:00:00.000Z', clientCount: 0, subscriberCount: 0, attachCount: 0, pendingPromptCount: 0, pendingPermissionCount: 0, hasActivePrompt: false, lastEventId: 0, ...extra, }; } let root: Root | null = null; let container: HTMLDivElement | null = null; let onOpenSession: ReturnType; let openSpy: ReturnType; const originalOpen = window.open; beforeEach(() => { connectionState = { sessionId: 's-run', capabilities: { features: [] }, workspaceCwd: '/w', }; sessionsState = { sessions: [], loading: false }; statusState = { report: { full: { sessions: [] } } }; sessionsReload.mockClear(); statusReload.mockClear(); onOpenSession = vi.fn(); openSpy = vi.fn().mockReturnValue({ focus: vi.fn() }); window.open = openSpy as unknown as typeof window.open; }); afterEach(() => { act(() => root?.unmount()); container?.remove(); root = null; container = null; window.open = originalOpen; }); function render(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); act(() => root!.render( , ), ); } function rerender(props: { onOpenSplit?: (ids: string[]) => void } = {}): void { act(() => root!.render( , ), ); } function cardLabels(): string[] { return Array.from(container!.querySelectorAll('ul li')).map( (li) => li.querySelectorAll('button')[0]?.textContent?.trim() ?? '', ); } function selectAllCheckbox(): HTMLInputElement { return container!.querySelector('input[type="checkbox"]') as HTMLInputElement; } function tabButton(): HTMLButtonElement { return Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Open in new tab'), ) as HTMLButtonElement; } describe('deriveSessionCards', () => { it('ranks needs-approval above running above idle, then by recency', () => { const sessions = [ session('s-idle', { displayName: 'idle', hasActivePrompt: false }), session('s-run', { displayName: 'run', hasActivePrompt: true }), session('s-appr', { displayName: 'appr', hasActivePrompt: false }), ]; const status = [statusSession('s-appr', { pendingPermissionCount: 1 })]; const cards = deriveSessionCards(sessions, status, 's-run'); expect(cards.map((c) => c.sessionId)).toEqual(['s-appr', 's-run', 's-idle']); expect(cards.map((c) => c.status)).toEqual([ 'needsApproval', 'running', 'idle', ]); }); it('needs-approval wins even when the prompt is also active (blocked turn)', () => { const sessions = [session('s', { hasActivePrompt: true })]; const status = [ statusSession('s', { hasActivePrompt: true, pendingPermissionCount: 2 }), ]; expect(deriveSessionCards(sessions, status, undefined)[0].status).toBe( 'needsApproval', ); }); it('treats sessions absent from the status report as idle', () => { const cards = deriveSessionCards([session('cold')], [], undefined); expect(cards[0].status).toBe('idle'); }); it('labels with displayName, falling back to a short id, and flags current', () => { const cards = deriveSessionCards( [ session('abcdef1234567890', {}), session('named', { displayName: ' Named ' }), ], [], 'named', ); const byId = new Map(cards.map((c) => [c.sessionId, c])); expect(byId.get('abcdef1234567890')!.label).toBe('abcdef12'); expect(byId.get('named')!.label).toBe('Named'); expect(byId.get('named')!.isCurrent).toBe(true); expect(byId.get('abcdef1234567890')!.isCurrent).toBe(false); }); it('carries model and client count from the status report', () => { const cards = deriveSessionCards( [session('s', { clientCount: 2 })], [statusSession('s', { currentModelId: 'qwen-max', clientCount: 2 })], undefined, ); expect(cards[0].model).toBe('qwen-max'); expect(cards[0].clientCount).toBe(2); }); }); describe('SessionOverviewPanel', () => { it('renders an empty state when there are no sessions', () => { render(); expect(container!.textContent).toContain('No sessions yet'); }); it('renders cards ranked with needs-approval first', () => { sessionsState.sessions = [ session('s-idle', { displayName: 'Bravo' }), session('s-run', { displayName: 'Alpha', hasActivePrompt: true }), session('s-appr', { displayName: 'Charlie' }), ]; statusState.report = { full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] }, }; render(); expect(cardLabels()).toEqual(['Charlie', 'Alpha', 'Bravo']); }); it('switches the current session when a card label is clicked', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; render(); const label = container!.querySelector('ul li button') as HTMLButtonElement; act(() => label.dispatchEvent(new MouseEvent('click', { bubbles: true }))); expect(onOpenSession).toHaveBeenCalledWith('s-run'); }); it('always shows selection + the "Open in new tab" batch action', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; render(); expect(container!.querySelector('input[type="checkbox"]')).not.toBeNull(); expect(container!.textContent).toContain('Open in new tab'); // The removed Window Management tiling affordance is gone. expect(container!.textContent).not.toContain('Tile across displays'); }); it('opens the selected sessions as a split in ONE new tab (?split=…)', () => { sessionsState.sessions = [ session('s-idle', { displayName: 'Bravo' }), session('s-appr', { displayName: 'Charlie' }), ]; statusState.report = { full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] }, }; render(); const selectAll = container!.querySelector( 'input[type="checkbox"]', ) as HTMLInputElement; act(() => selectAll.click()); const tabButton = Array.from(container!.querySelectorAll('button')).find( (b) => b.textContent?.includes('Open in new tab'), ) as HTMLButtonElement; expect(tabButton.disabled).toBe(false); act(() => tabButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); // A single new tab whose URL carries the ranked split (needs-approval first). expect(openSpy).toHaveBeenCalledTimes(1); const [url, target] = openSpy.mock.calls[0]; expect(target).toBe('_blank'); expect(decodeURIComponent(String(url))).toContain('split=s-appr,s-idle'); }); it('severs window.opener on the new split tab (token is in its URL)', () => { sessionsState.sessions = [session('s1', { displayName: 'One' })]; const opened = { focus: vi.fn(), opener: {} as unknown }; openSpy.mockReturnValue(opened); render(); act(() => selectAllCheckbox().click()); act(() => tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), ); // The opener link is cut so the authenticated split tab can't script us. expect(opened.opener).toBeNull(); expect(opened.focus).toHaveBeenCalledTimes(1); }); it('opens the selected sessions in split, in ranked order', () => { sessionsState.sessions = [ session('s-idle', { displayName: 'Bravo' }), session('s-appr', { displayName: 'Charlie' }), ]; statusState.report = { full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] }, }; const onOpenSplit = vi.fn(); render({ onOpenSplit }); const selectAll = container!.querySelector( 'input[type="checkbox"]', ) as HTMLInputElement; act(() => selectAll.click()); const splitButton = Array.from(container!.querySelectorAll('button')).find( (b) => b.textContent?.includes('Open in split'), ) as HTMLButtonElement; act(() => splitButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); // needs-approval (Charlie) is ranked ahead of idle (Bravo). expect(onOpenSplit).toHaveBeenCalledWith(['s-appr', 's-idle']); }); it('toggling a card checkbox selects it without switching sessions', () => { sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })]; render(); const cardCheckbox = container!.querySelector( 'ul li input[type="checkbox"]', ) as HTMLInputElement; act(() => cardCheckbox.click()); expect(cardCheckbox.checked).toBe(true); // Selecting must not navigate — that's what the label button is for. expect(onOpenSession).not.toHaveBeenCalled(); }); it('surfaces the popup-blocked notice when window.open is blocked', () => { sessionsState.sessions = [session('s1', { displayName: 'One' })]; openSpy.mockReturnValue(null); // browser blocked the pop-up render(); act(() => selectAllCheckbox().click()); act(() => tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), ); expect(container!.textContent).toContain('Pop-up blocked'); }); it('surfaces a refresh failure inline while keeping the last-good cards', () => { sessionsState.sessions = [session('s1', { displayName: 'One' })]; sessionsState.error = new Error('daemon unreachable'); render(); // The cards stay on screen (not replaced by the empty/error-only state)… expect(cardLabels()).toEqual(['One']); // …and the failure is still visible rather than silently swallowed. expect(container!.textContent).toContain('daemon unreachable'); }); it('does not re-select a session that left the list and came back', () => { sessionsState.sessions = [ session('a', { displayName: 'A' }), session('b', { displayName: 'B' }), ]; render(); act(() => selectAllCheckbox().click()); // a + b selected expect(selectAllCheckbox().checked).toBe(true); // 'a' leaves the list → its selection is pruned. sessionsState.sessions = [session('b', { displayName: 'B' })]; rerender(); // 'a' comes back → it must NOT be pre-selected (only b remains selected). sessionsState.sessions = [ session('a', { displayName: 'A' }), session('b', { displayName: 'B' }), ]; rerender(); expect(selectAllCheckbox().checked).toBe(false); act(() => tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), ); // Only 'b' is opened — the returned 'a' was pruned, not silently reselected. const url = new URL(String(openSpy.mock.calls[0][0])); expect(url.searchParams.get('split')).toBe('b'); }); it('caps the split to 6 sessions and warns when more are selected', () => { sessionsState.sessions = Array.from({ length: 8 }, (_, i) => session(`s${i}`, { displayName: `S${i}` }), ); const onOpenSplit = vi.fn(); render({ onOpenSplit }); act(() => selectAllCheckbox().click()); // all 8 selected // A hint tells the user only the first 6 will open. expect(container!.textContent).toContain('Only the first 6'); // New-tab URL carries at most 6 ids. act(() => tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })), ); const split = new URL(String(openSpy.mock.calls[0][0])).searchParams.get( 'split', ); expect(split!.split(',')).toHaveLength(6); // In-window split is likewise capped. const splitButton = Array.from(container!.querySelectorAll('button')).find( (b) => b.textContent?.includes('Open in split'), ) as HTMLButtonElement; act(() => splitButton.dispatchEvent(new MouseEvent('click', { bubbles: true })), ); expect(onOpenSplit.mock.calls[0][0]).toHaveLength(6); }); }); describe('SessionOverviewPanel polling', () => { it('polls the session list on an interval', async () => { sessionsState.sessions = [session('s')]; vi.useFakeTimers(); try { render(); sessionsReload.mockClear(); await vi.advanceTimersByTimeAsync(3100); expect(sessionsReload).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); it('skips polling while the tab is hidden', async () => { sessionsState.sessions = [session('s')]; Object.defineProperty(document, 'hidden', { configurable: true, get: () => true, }); vi.useFakeTimers(); try { render(); sessionsReload.mockClear(); await vi.advanceTimersByTimeAsync(6200); expect(sessionsReload).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); Object.defineProperty(document, 'hidden', { configurable: true, get: () => false, }); } }); it('does not overlap polls while one is still in flight', async () => { sessionsState.sessions = [session('s')]; // A never-resolving reload keeps the in-flight guard set. sessionsReload.mockImplementation(() => new Promise(() => {})); vi.useFakeTimers(); try { render(); sessionsReload.mockClear(); await vi.advanceTimersByTimeAsync(9300); // three list ticks expect(sessionsReload).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); sessionsReload.mockReset(); sessionsReload.mockImplementation(async () => sessionsState.sessions); } }); it('polls the richer status report on its own slower interval', async () => { sessionsState.sessions = [session('s')]; vi.useFakeTimers(); try { render(); statusReload.mockClear(); // One list tick (3s) must NOT drive a status refresh… await vi.advanceTimersByTimeAsync(3100); expect(statusReload).not.toHaveBeenCalled(); // …but crossing the 10s status cadence does, exactly once. await vi.advanceTimersByTimeAsync(7000); expect(statusReload).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); });