diff --git a/v2/src/lib/adapters/agent-bridge.test.ts b/v2/src/lib/adapters/agent-bridge.test.ts new file mode 100644 index 0000000..e9a2b8e --- /dev/null +++ b/v2/src/lib/adapters/agent-bridge.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Use vi.hoisted to declare mocks that are accessible inside vi.mock factories +const { mockInvoke, mockListen } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockListen: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: mockListen, +})); + +import { + queryAgent, + stopAgent, + isAgentReady, + restartAgent, + onSidecarMessage, + onSidecarExited, + type AgentQueryOptions, +} from './agent-bridge'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('agent-bridge', () => { + describe('queryAgent', () => { + it('invokes agent_query with options', async () => { + mockInvoke.mockResolvedValue(undefined); + + const options: AgentQueryOptions = { + session_id: 'sess-1', + prompt: 'Hello Claude', + cwd: '/tmp', + max_turns: 10, + max_budget_usd: 1.0, + }; + + await queryAgent(options); + + expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options }); + }); + + it('passes minimal options (only required fields)', async () => { + mockInvoke.mockResolvedValue(undefined); + + const options: AgentQueryOptions = { + session_id: 'sess-2', + prompt: 'Do something', + }; + + await queryAgent(options); + + expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options }); + }); + + it('propagates invoke errors', async () => { + mockInvoke.mockRejectedValue(new Error('Sidecar not running')); + + await expect( + queryAgent({ session_id: 'sess-3', prompt: 'test' }), + ).rejects.toThrow('Sidecar not running'); + }); + }); + + describe('stopAgent', () => { + it('invokes agent_stop with session ID', async () => { + mockInvoke.mockResolvedValue(undefined); + + await stopAgent('sess-1'); + + expect(mockInvoke).toHaveBeenCalledWith('agent_stop', { sessionId: 'sess-1' }); + }); + }); + + describe('isAgentReady', () => { + it('returns true when sidecar is ready', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await isAgentReady(); + + expect(result).toBe(true); + expect(mockInvoke).toHaveBeenCalledWith('agent_ready'); + }); + + it('returns false when sidecar is not ready', async () => { + mockInvoke.mockResolvedValue(false); + + const result = await isAgentReady(); + + expect(result).toBe(false); + }); + }); + + describe('restartAgent', () => { + it('invokes agent_restart', async () => { + mockInvoke.mockResolvedValue(undefined); + + await restartAgent(); + + expect(mockInvoke).toHaveBeenCalledWith('agent_restart'); + }); + }); + + describe('onSidecarMessage', () => { + it('registers listener on sidecar-message event', async () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const callback = vi.fn(); + const result = await onSidecarMessage(callback); + + expect(mockListen).toHaveBeenCalledWith('sidecar-message', expect.any(Function)); + expect(result).toBe(unlisten); + }); + + it('extracts payload and passes to callback', async () => { + mockListen.mockImplementation(async (_event: string, handler: (e: unknown) => void) => { + // Simulate Tauri event delivery + handler({ + payload: { + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'system', subtype: 'init' }, + }, + }); + return vi.fn(); + }); + + const callback = vi.fn(); + await onSidecarMessage(callback); + + expect(callback).toHaveBeenCalledWith({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'system', subtype: 'init' }, + }); + }); + }); + + describe('onSidecarExited', () => { + it('registers listener on sidecar-exited event', async () => { + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const callback = vi.fn(); + const result = await onSidecarExited(callback); + + expect(mockListen).toHaveBeenCalledWith('sidecar-exited', expect.any(Function)); + expect(result).toBe(unlisten); + }); + + it('invokes callback without arguments on exit', async () => { + mockListen.mockImplementation(async (_event: string, handler: () => void) => { + handler(); + return vi.fn(); + }); + + const callback = vi.fn(); + await onSidecarExited(callback); + + expect(callback).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/v2/src/lib/adapters/sdk-messages.test.ts b/v2/src/lib/adapters/sdk-messages.test.ts index d847719..6e94d22 100644 --- a/v2/src/lib/adapters/sdk-messages.test.ts +++ b/v2/src/lib/adapters/sdk-messages.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { adaptSDKMessage } from './sdk-messages'; -import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent, ErrorContent } from './sdk-messages'; +import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './sdk-messages'; // Mock crypto.randomUUID for deterministic IDs when uuid is missing beforeEach(() => { diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts new file mode 100644 index 0000000..b397387 --- /dev/null +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Hoisted mocks --- + +const { + capturedCallbacks, + mockUnlistenMsg, + mockUnlistenExit, + mockRestartAgent, + mockUpdateAgentStatus, + mockSetAgentSdkSessionId, + mockSetAgentModel, + mockAppendAgentMessages, + mockUpdateAgentCost, + mockGetAgentSessions, + mockNotify, +} = vi.hoisted(() => ({ + capturedCallbacks: { + msg: null as ((msg: any) => void) | null, + exit: null as (() => void) | null, + }, + mockUnlistenMsg: vi.fn(), + mockUnlistenExit: vi.fn(), + mockRestartAgent: vi.fn(), + mockUpdateAgentStatus: vi.fn(), + mockSetAgentSdkSessionId: vi.fn(), + mockSetAgentModel: vi.fn(), + mockAppendAgentMessages: vi.fn(), + mockUpdateAgentCost: vi.fn(), + mockGetAgentSessions: vi.fn().mockReturnValue([]), + mockNotify: vi.fn(), +})); + +vi.mock('./adapters/agent-bridge', () => ({ + onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => { + capturedCallbacks.msg = cb; + return mockUnlistenMsg; + }), + onSidecarExited: vi.fn(async (cb: () => void) => { + capturedCallbacks.exit = cb; + return mockUnlistenExit; + }), + restartAgent: (...args: unknown[]) => mockRestartAgent(...args), +})); + +vi.mock('./adapters/sdk-messages', () => ({ + adaptSDKMessage: vi.fn((raw: Record) => { + if (raw.type === 'system' && raw.subtype === 'init') { + return [{ + id: 'msg-1', + type: 'init', + content: { sessionId: 'sdk-sess', model: 'claude-sonnet-4-20250514', cwd: '/tmp', tools: [] }, + timestamp: Date.now(), + }]; + } + if (raw.type === 'result') { + return [{ + id: 'msg-2', + type: 'cost', + content: { + totalCostUsd: 0.05, + durationMs: 5000, + inputTokens: 500, + outputTokens: 200, + numTurns: 2, + isError: false, + }, + timestamp: Date.now(), + }]; + } + if (raw.type === 'assistant') { + return [{ + id: 'msg-3', + type: 'text', + content: { text: 'Hello' }, + timestamp: Date.now(), + }]; + } + return []; + }), +})); + +vi.mock('./stores/agents.svelte', () => ({ + updateAgentStatus: (...args: unknown[]) => mockUpdateAgentStatus(...args), + setAgentSdkSessionId: (...args: unknown[]) => mockSetAgentSdkSessionId(...args), + setAgentModel: (...args: unknown[]) => mockSetAgentModel(...args), + appendAgentMessages: (...args: unknown[]) => mockAppendAgentMessages(...args), + updateAgentCost: (...args: unknown[]) => mockUpdateAgentCost(...args), + getAgentSessions: () => mockGetAgentSessions(), +})); + +vi.mock('./stores/notifications.svelte', () => ({ + notify: (...args: unknown[]) => mockNotify(...args), +})); + +// Use fake timers to control setTimeout in sidecar crash recovery +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + capturedCallbacks.msg = null; + capturedCallbacks.exit = null; + mockRestartAgent.mockResolvedValue(undefined); + mockGetAgentSessions.mockReturnValue([]); +}); + +// We need to dynamically import the dispatcher in each test to get fresh module state. +// However, vi.mock is module-scoped so the mocks persist. The module-level restartAttempts +// and sidecarAlive variables persist across tests since they share the same module instance. +// We work around this by resetting via the exported setSidecarAlive and stopAgentDispatcher. + +import { + startAgentDispatcher, + stopAgentDispatcher, + isSidecarAlive, + setSidecarAlive, +} from './agent-dispatcher'; + +// Stop any previous dispatcher between tests so `unlistenMsg` is null and start works +beforeEach(() => { + stopAgentDispatcher(); +}); + +afterEach(async () => { + vi.useRealTimers(); +}); + +// Need afterEach import +import { afterEach } from 'vitest'; + +describe('agent-dispatcher', () => { + describe('startAgentDispatcher', () => { + it('registers sidecar message and exit listeners', async () => { + await startAgentDispatcher(); + + expect(capturedCallbacks.msg).toBeTypeOf('function'); + expect(capturedCallbacks.exit).toBeTypeOf('function'); + }); + + it('does not register duplicate listeners on repeated calls', async () => { + await startAgentDispatcher(); + await startAgentDispatcher(); // second call should be no-op + + const { onSidecarMessage } = await import('./adapters/agent-bridge'); + expect(onSidecarMessage).toHaveBeenCalledTimes(1); + }); + + it('sets sidecarAlive to true on start', async () => { + setSidecarAlive(false); + await startAgentDispatcher(); + expect(isSidecarAlive()).toBe(true); + }); + }); + + describe('message routing', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('routes agent_started to updateAgentStatus(running)', () => { + capturedCallbacks.msg!({ + type: 'agent_started', + sessionId: 'sess-1', + }); + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'running'); + }); + + it('routes agent_stopped to updateAgentStatus(done) and notifies', () => { + capturedCallbacks.msg!({ + type: 'agent_stopped', + sessionId: 'sess-1', + }); + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'done'); + expect(mockNotify).toHaveBeenCalledWith('success', expect.stringContaining('completed')); + }); + + it('routes agent_error to updateAgentStatus(error) with message', () => { + capturedCallbacks.msg!({ + type: 'agent_error', + sessionId: 'sess-1', + message: 'Process crashed', + }); + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'error', 'Process crashed'); + expect(mockNotify).toHaveBeenCalledWith('error', expect.stringContaining('Process crashed')); + }); + + it('ignores messages without sessionId', () => { + capturedCallbacks.msg!({ + type: 'agent_started', + }); + + expect(mockUpdateAgentStatus).not.toHaveBeenCalled(); + }); + + it('handles agent_log silently (no-op)', () => { + capturedCallbacks.msg!({ + type: 'agent_log', + sessionId: 'sess-1', + message: 'Debug info', + }); + + expect(mockUpdateAgentStatus).not.toHaveBeenCalled(); + expect(mockNotify).not.toHaveBeenCalled(); + }); + }); + + describe('agent_event routing via SDK adapter', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('routes init event to setAgentSdkSessionId and setAgentModel', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'system', subtype: 'init' }, + }); + + expect(mockSetAgentSdkSessionId).toHaveBeenCalledWith('sess-1', 'sdk-sess'); + expect(mockSetAgentModel).toHaveBeenCalledWith('sess-1', 'claude-sonnet-4-20250514'); + expect(mockAppendAgentMessages).toHaveBeenCalled(); + }); + + it('routes cost event to updateAgentCost and updateAgentStatus', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'result' }, + }); + + expect(mockUpdateAgentCost).toHaveBeenCalledWith('sess-1', { + costUsd: 0.05, + inputTokens: 500, + outputTokens: 200, + numTurns: 2, + durationMs: 5000, + }); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'done'); + }); + + it('appends messages to agent session', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'assistant' }, + }); + + expect(mockAppendAgentMessages).toHaveBeenCalledWith('sess-1', [ + expect.objectContaining({ type: 'text', content: { text: 'Hello' } }), + ]); + }); + + it('does not append when adapter returns empty array', () => { + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-1', + event: { type: 'unknown_event' }, + }); + + expect(mockAppendAgentMessages).not.toHaveBeenCalled(); + }); + }); + + describe('sidecar exit handling', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('marks running sessions as errored on exit', async () => { + mockGetAgentSessions.mockReturnValue([ + { id: 'sess-1', status: 'running' }, + { id: 'sess-2', status: 'done' }, + { id: 'sess-3', status: 'starting' }, + ]); + + // Trigger exit -- don't await, since it has internal setTimeout + const exitPromise = capturedCallbacks.exit!(); + // Advance past the backoff delay (up to 4s) + await vi.advanceTimersByTimeAsync(5000); + await exitPromise; + + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'error', 'Sidecar crashed'); + expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-3', 'error', 'Sidecar crashed'); + // sess-2 (done) should not be updated with 'error'/'Sidecar crashed' + const calls = mockUpdateAgentStatus.mock.calls; + const sess2Calls = calls.filter((c: unknown[]) => c[0] === 'sess-2'); + expect(sess2Calls).toHaveLength(0); + }); + + it('attempts auto-restart and notifies with warning', async () => { + const exitPromise = capturedCallbacks.exit!(); + await vi.advanceTimersByTimeAsync(5000); + await exitPromise; + + expect(mockRestartAgent).toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith('warning', expect.stringContaining('restarting')); + }); + }); + + describe('stopAgentDispatcher', () => { + it('calls unlisten functions', async () => { + await startAgentDispatcher(); + stopAgentDispatcher(); + + expect(mockUnlistenMsg).toHaveBeenCalled(); + expect(mockUnlistenExit).toHaveBeenCalled(); + }); + + it('allows re-registering after stop', async () => { + await startAgentDispatcher(); + stopAgentDispatcher(); + await startAgentDispatcher(); + + const { onSidecarMessage } = await import('./adapters/agent-bridge'); + expect(onSidecarMessage).toHaveBeenCalledTimes(2); + }); + }); + + describe('isSidecarAlive / setSidecarAlive', () => { + it('defaults to true after start', async () => { + await startAgentDispatcher(); + expect(isSidecarAlive()).toBe(true); + }); + + it('can be set manually', () => { + setSidecarAlive(false); + expect(isSidecarAlive()).toBe(false); + setSidecarAlive(true); + expect(isSidecarAlive()).toBe(true); + }); + }); +}); diff --git a/v2/src/lib/stores/layout.test.ts b/v2/src/lib/stores/layout.test.ts new file mode 100644 index 0000000..ffd4b1b --- /dev/null +++ b/v2/src/lib/stores/layout.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock session-bridge before importing the layout store +vi.mock('../adapters/session-bridge', () => ({ + listSessions: vi.fn().mockResolvedValue([]), + saveSession: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + updateSessionTitle: vi.fn().mockResolvedValue(undefined), + touchSession: vi.fn().mockResolvedValue(undefined), + saveLayout: vi.fn().mockResolvedValue(undefined), + loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }), +})); + +import { + getPanes, + getActivePreset, + getFocusedPaneId, + addPane, + removePane, + focusPane, + focusPaneByIndex, + setPreset, + renamePaneTitle, + getGridTemplate, + getPaneGridArea, + type LayoutPreset, + type Pane, +} from './layout.svelte'; + +// Helper to reset module state between tests +// The layout store uses module-level $state, so we need to clean up +function clearAllPanes(): void { + const panes = getPanes(); + const ids = panes.map(p => p.id); + for (const id of ids) { + removePane(id); + } +} + +beforeEach(() => { + clearAllPanes(); + setPreset('1-col'); + vi.clearAllMocks(); +}); + +describe('layout store', () => { + describe('addPane', () => { + it('adds a pane to the list', () => { + addPane({ id: 'p1', type: 'terminal', title: 'Terminal 1' }); + + const panes = getPanes(); + expect(panes).toHaveLength(1); + expect(panes[0].id).toBe('p1'); + expect(panes[0].type).toBe('terminal'); + expect(panes[0].title).toBe('Terminal 1'); + }); + + it('sets focused to false initially then focuses via focusPane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + + // addPane calls focusPane internally, so the pane should be focused + expect(getFocusedPaneId()).toBe('p1'); + const panes = getPanes(); + expect(panes[0].focused).toBe(true); + }); + + it('focuses the newly added pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'agent', title: 'Agent 1' }); + + expect(getFocusedPaneId()).toBe('p2'); + }); + + it('calls autoPreset when adding panes', () => { + // 1 pane -> 1-col + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + expect(getActivePreset()).toBe('1-col'); + + // 2 panes -> 2-col + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + expect(getActivePreset()).toBe('2-col'); + + // 3 panes -> master-stack + addPane({ id: 'p3', type: 'terminal', title: 'T3' }); + expect(getActivePreset()).toBe('master-stack'); + + // 4+ panes -> 2x2 + addPane({ id: 'p4', type: 'terminal', title: 'T4' }); + expect(getActivePreset()).toBe('2x2'); + }); + }); + + describe('removePane', () => { + it('removes a pane by id', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + removePane('p1'); + + const panes = getPanes(); + expect(panes).toHaveLength(1); + expect(panes[0].id).toBe('p2'); + }); + + it('focuses the first remaining pane when focused pane is removed', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + addPane({ id: 'p3', type: 'terminal', title: 'T3' }); + + // p3 is focused (last added) + expect(getFocusedPaneId()).toBe('p3'); + + removePane('p3'); + + // Should focus p1 (first remaining) + expect(getFocusedPaneId()).toBe('p1'); + }); + + it('sets focusedPaneId to null when last pane is removed', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + removePane('p1'); + + expect(getFocusedPaneId()).toBeNull(); + }); + + it('adjusts preset via autoPreset after removal', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + expect(getActivePreset()).toBe('2-col'); + + removePane('p2'); + expect(getActivePreset()).toBe('1-col'); + }); + + it('does not change focus if removed pane was not focused', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + // p2 is focused (last added). Remove p1 + focusPane('p2'); + removePane('p1'); + + expect(getFocusedPaneId()).toBe('p2'); + }); + }); + + describe('focusPane', () => { + it('sets focused flag on the target pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + focusPane('p1'); + + const panes = getPanes(); + expect(panes.find(p => p.id === 'p1')?.focused).toBe(true); + expect(panes.find(p => p.id === 'p2')?.focused).toBe(false); + expect(getFocusedPaneId()).toBe('p1'); + }); + }); + + describe('focusPaneByIndex', () => { + it('focuses pane at the given index', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + + focusPaneByIndex(0); + + expect(getFocusedPaneId()).toBe('p1'); + }); + + it('ignores out-of-bounds indices', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + + focusPaneByIndex(5); + + // Should remain on p1 + expect(getFocusedPaneId()).toBe('p1'); + }); + + it('ignores negative indices', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + + focusPaneByIndex(-1); + + expect(getFocusedPaneId()).toBe('p1'); + }); + }); + + describe('setPreset', () => { + it('overrides the active preset', () => { + setPreset('3-col'); + expect(getActivePreset()).toBe('3-col'); + }); + + it('allows setting any valid preset', () => { + const presets: LayoutPreset[] = ['1-col', '2-col', '3-col', '2x2', 'master-stack']; + for (const preset of presets) { + setPreset(preset); + expect(getActivePreset()).toBe(preset); + } + }); + }); + + describe('renamePaneTitle', () => { + it('updates the title of a pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'Old Title' }); + + renamePaneTitle('p1', 'New Title'); + + const panes = getPanes(); + expect(panes[0].title).toBe('New Title'); + }); + + it('does nothing for non-existent pane', () => { + addPane({ id: 'p1', type: 'terminal', title: 'Title' }); + + renamePaneTitle('p-nonexistent', 'New Title'); + + expect(getPanes()[0].title).toBe('Title'); + }); + }); + + describe('getGridTemplate', () => { + it('returns 1fr / 1fr for 1-col', () => { + setPreset('1-col'); + expect(getGridTemplate()).toEqual({ columns: '1fr', rows: '1fr' }); + }); + + it('returns 1fr 1fr / 1fr for 2-col', () => { + setPreset('2-col'); + expect(getGridTemplate()).toEqual({ columns: '1fr 1fr', rows: '1fr' }); + }); + + it('returns 1fr 1fr 1fr / 1fr for 3-col', () => { + setPreset('3-col'); + expect(getGridTemplate()).toEqual({ columns: '1fr 1fr 1fr', rows: '1fr' }); + }); + + it('returns 1fr 1fr / 1fr 1fr for 2x2', () => { + setPreset('2x2'); + expect(getGridTemplate()).toEqual({ columns: '1fr 1fr', rows: '1fr 1fr' }); + }); + + it('returns 2fr 1fr / 1fr 1fr for master-stack', () => { + setPreset('master-stack'); + expect(getGridTemplate()).toEqual({ columns: '2fr 1fr', rows: '1fr 1fr' }); + }); + }); + + describe('getPaneGridArea', () => { + it('returns grid area for first pane in master-stack', () => { + setPreset('master-stack'); + expect(getPaneGridArea(0)).toBe('1 / 1 / 3 / 2'); + }); + + it('returns undefined for non-first panes in master-stack', () => { + setPreset('master-stack'); + expect(getPaneGridArea(1)).toBeUndefined(); + expect(getPaneGridArea(2)).toBeUndefined(); + }); + + it('returns undefined for all panes in non-master-stack presets', () => { + setPreset('2-col'); + expect(getPaneGridArea(0)).toBeUndefined(); + expect(getPaneGridArea(1)).toBeUndefined(); + }); + }); + + describe('autoPreset behavior', () => { + it('0 panes -> 1-col', () => { + expect(getActivePreset()).toBe('1-col'); + }); + + it('1 pane -> 1-col', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + expect(getActivePreset()).toBe('1-col'); + }); + + it('2 panes -> 2-col', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + expect(getActivePreset()).toBe('2-col'); + }); + + it('3 panes -> master-stack', () => { + addPane({ id: 'p1', type: 'terminal', title: 'T1' }); + addPane({ id: 'p2', type: 'terminal', title: 'T2' }); + addPane({ id: 'p3', type: 'terminal', title: 'T3' }); + expect(getActivePreset()).toBe('master-stack'); + }); + + it('4+ panes -> 2x2', () => { + for (let i = 1; i <= 5; i++) { + addPane({ id: `p${i}`, type: 'terminal', title: `T${i}` }); + } + expect(getActivePreset()).toBe('2x2'); + }); + }); +}); diff --git a/v2/tests/e2e/README.md b/v2/tests/e2e/README.md new file mode 100644 index 0000000..ba79ec1 --- /dev/null +++ b/v2/tests/e2e/README.md @@ -0,0 +1,52 @@ +# E2E Tests (WebDriver) + +Tauri apps use the WebDriver protocol for E2E testing (not Playwright directly). +The app runs inside WebKit2GTK on Linux, so tests interact with the real WebView. + +## Prerequisites + +- Built Tauri app (`npm run tauri build`) +- Display server (X11 or Wayland) -- headless Xvfb works for CI +- `tauri-driver` installed (`cargo install tauri-driver`) +- WebdriverIO (`npm install --save-dev @wdio/cli @wdio/local-runner @wdio/mocha-framework`) + +## Running + +```bash +# Terminal 1: Start tauri-driver (bridges WebDriver to WebKit2GTK) +tauri-driver + +# Terminal 2: Run tests +npm run test:e2e +``` + +## CI setup (headless) + +```bash +# Install virtual framebuffer +sudo apt install xvfb + +# Run with Xvfb wrapper +xvfb-run npm run test:e2e +``` + +## Writing tests + +Tests use WebdriverIO. Example: + +```typescript +import { browser } from '@wdio/globals'; + +describe('BTerminal', () => { + it('should show the terminal pane on startup', async () => { + const terminal = await browser.$('.terminal-pane'); + await expect(terminal).toBeDisplayed(); + }); +}); +``` + +## References + +- Tauri WebDriver docs: https://v2.tauri.app/develop/tests/webdriver/ +- WebdriverIO docs: https://webdriver.io/ +- tauri-driver: https://crates.io/crates/tauri-driver