diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e9c768..06de557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,25 @@ jobs: - name: Run vitest run: npm run test:run + analytics: + name: Analytics worker (vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: workers/ct-analytics + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: workers/ct-analytics/package-lock.json + - name: Install dependencies + run: npm ci + - name: Run vitest + run: npm run test:run + backend: name: Backend (cargo test) runs-on: ubuntu-latest diff --git a/docs/testing-audit.md b/docs/testing-audit.md new file mode 100644 index 0000000..e220e44 --- /dev/null +++ b/docs/testing-audit.md @@ -0,0 +1,55 @@ +# Test coverage audit — 2026-09-10 + +This pass reviewed the test inventory, application lifecycle wiring, hooks, +stores, desktop window helpers, Rust test coverage, worker tests, and CI. +It adds targeted unit and integration tests; it does not establish complete +statement coverage or run the packaged desktop application end to end. + +## Added coverage + +| Suite | Tests | Behavior protected | +| --- | ---: | --- | +| `src/hooks/useWindowFocused.test.ts` | 5 | Initial focus, events, native failures, delayed registration cleanup, stale responses | +| `src/hooks/useNowTick.test.ts` | 4 | Activity ticks, idle suspension, restart, timer cleanup | +| `src/hooks/usePreventWebviewReload.test.ts` | 5 | Native menus on editable surfaces, live terminal checks, global handler cleanup | +| `src/hooks/useSessionStateDetection.test.ts` | 8 | Busy/waiting transitions, notification deduplication and rearming, focus, DND, sound, stopped sessions, unmounted buffers | +| `src/lib/windowLayout.test.ts` | 11 | Stable identities, independent window updates, corrupt storage recovery, geometry, unavailable native windows | +| `src/lib/tabTransfer.test.ts` | 12 | Window hit testing, overlay exclusion, handoff failures, scrollback adoption, acknowledgements, listener cleanup | +| `src/store/pasteStore.test.ts` | 5 | Ordering, preview/history limits, immutable updates, terminal isolation, disk hydration failures | + +The new regression tests failed before fixes for two focus lifecycle defects +(late listener registration and stale initial focus queries) and malformed +saved layouts. Layout reads now validate entries and discard invalid geometry +while retaining valid session identities. + +CI now runs the analytics worker's existing test suite independently of the +frontend and Rust jobs. + +## Validation + +- `npm.cmd run test:run`: 554 tests passed in 65 files (baseline: 504 in 58). +- `npm.cmd run build`: TypeScript and production bundling passed. Vite reported + large chunks and ineffective dynamic imports; bundle optimization remains separate work. +- `cargo test --locked --offline` in `src-tauri`: 267 tests passed. +- `npm.cmd run test:run` in `workers/ct-analytics`: 15 tests passed. + +Use `npm` instead of `npm.cmd` on Linux/macOS. The `.cmd` entry point avoids +PowerShell's script execution restriction on this development machine. + +## Highest-value follow-up coverage + +| Priority | Area | Scenarios to exercise | +| --- | --- | --- | +| High | Packaged desktop lifecycle (`App.tsx`, `TerminalView.tsx`) | Launch, create a real shell, receive output, close, restart, restore; verify cleanup and persisted state across actual processes | +| High | Multi-window failure recovery (`tabTransfer.ts`) | Destination creation fails after source detachment; receiver closes mid-adoption; transfers contain missing terminal IDs; verify tabs remain recoverable | +| High | Editor and LSP (`FileEditorView.tsx`, `lspClient.ts`) | Unsaved-file close prompts, save failures, delayed document opens, debounced changes, diagnostics arriving after close | +| High | Keyboard dispatch (`useKeyboardShortcuts.ts`) | Editable controls versus terminal focus, modal priority, platform modifiers, subscriber cleanup | +| Medium | Paste hydration concurrency (`pasteStore.ts`) | Add/remove/clear while a disk read is in flight; overlapping reads resolving out of order | +| Medium | Worker HTTP routes (`workers/ct-analytics/src/index.ts`) | Request validation, rate limiting, database failures and response contracts using isolated database fixtures | +| Medium | UI workflows | Setup, workspace/worktree creation, global search, Git operations and update failure/retry flows | + +The new desktop integration tests mock Tauri boundaries. They verify frontend +decisions and protocol ordering, but cannot validate native window creation, +OS notification delivery, real PTY lifetime, or cross-window event delivery. +The CI change was inspected locally; its hosted execution will occur on the +next push or pull request. diff --git a/src/hooks/useNowTick.test.ts b/src/hooks/useNowTick.test.ts new file mode 100644 index 0000000..8511f6f --- /dev/null +++ b/src/hooks/useNowTick.test.ts @@ -0,0 +1,45 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getActiveTerminalIds } from '../lib/terminalActivity'; +import { useNowTick } from './useNowTick'; + +vi.mock('../lib/terminalActivity', () => ({ getActiveTerminalIds: vi.fn() })); + +describe('useNowTick', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + vi.mocked(getActiveTerminalIds).mockReturnValue(['active']); + }); + afterEach(() => { cleanup(); vi.useRealTimers(); }); + + it('updates the activity clock every half second while work continues', () => { + const { result } = renderHook(useNowTick); + expect(result.current).toBe(10_000); + act(() => vi.advanceTimersByTime(500)); + expect(result.current).toBe(10_500); + act(() => vi.advanceTimersByTime(500)); + expect(result.current).toBe(11_000); + expect(getActiveTerminalIds).toHaveBeenCalledWith(5000); + }); + + it('stops updating when idle and resumes after a new activity burst', () => { + vi.mocked(getActiveTerminalIds).mockReturnValue([]); + const { result } = renderHook(useNowTick); + act(() => vi.advanceTimersByTime(500)); + const idleTime = result.current; + act(() => vi.advanceTimersByTime(3000)); + expect(result.current).toBe(idleTime); + vi.mocked(getActiveTerminalIds).mockReturnValue(['active']); + act(() => vi.advanceTimersByTime(1000)); + expect(result.current).toBe(Date.now()); + }); + + it.each([true, false])('cleans up all timers when unmounted (active=%s)', (active) => { + vi.mocked(getActiveTerminalIds).mockReturnValue(active ? ['active'] : []); + const { unmount } = renderHook(useNowTick); + act(() => vi.advanceTimersByTime(500)); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/hooks/usePreventWebviewReload.test.ts b/src/hooks/usePreventWebviewReload.test.ts new file mode 100644 index 0000000..8582671 --- /dev/null +++ b/src/hooks/usePreventWebviewReload.test.ts @@ -0,0 +1,46 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePreventWebviewReload } from './usePreventWebviewReload'; + +const state = vi.hoisted(() => ({ terminals: new Map() })); +vi.mock('../store/terminalStore', () => ({ useTerminalStore: { getState: () => state } })); + +describe('reload protection', () => { + beforeEach(() => state.terminals.clear()); + afterEach(cleanup); + + it.each(['input', 'textarea', 'div'])('blocks native refresh menus on %s elements', (tag) => { + renderHook(usePreventWebviewReload); + const target = document.createElement(tag); + document.body.appendChild(target); + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + target.remove(); + }); + + it('reads current terminals at unload time, including terminals opened after mounting', () => { + renderHook(usePreventWebviewReload); + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }; + expect(unload()).toBe(false); + state.terminals.set('one', {}); + expect(unload()).toBe(true); + state.terminals.clear(); + expect(unload()).toBe(false); + }); + + it('removes both global handlers on unmount', () => { + state.terminals.set('one', {}); + const { unmount } = renderHook(usePreventWebviewReload); + unmount(); + for (const type of ['contextmenu', 'beforeunload']) { + const event = new Event(type, { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + }); +}); diff --git a/src/hooks/useSessionStateDetection.test.ts b/src/hooks/useSessionStateDetection.test.ts new file mode 100644 index 0000000..8386f1c --- /dev/null +++ b/src/hooks/useSessionStateDetection.test.ts @@ -0,0 +1,130 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSessionStateDetection } from './useSessionStateDetection'; + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(), sound: vi.fn(), focused: false, dnd: false, + lastOutput: vi.fn(), classify: vi.fn(), setState: vi.fn(), + terminals: new Map(), states: new Map(), + app: { dndEnabled: false, dndStart: '22:00', dndEnd: '08:00', notificationSoundEnabled: true }, +})); +vi.mock('../store/terminalStore', () => ({ useTerminalStore: { getState: () => ({ + terminals: mocks.terminals, terminalStates: mocks.states, activeTerminalId: 'one', setTerminalState: mocks.setState, +}) } })); +vi.mock('../store/appStore', () => ({ useAppStore: { getState: () => mocks.app } })); +vi.mock('../lib/terminalActivity', () => ({ getLastOutputAt: mocks.lastOutput })); +vi.mock('../lib/terminalState', () => ({ classifySettled: mocks.classify })); +vi.mock('../lib/notificationGate', () => ({ isWithinDnd: () => mocks.dnd, playNotificationSound: mocks.sound })); +vi.mock('./useNotification', () => ({ useNotification: () => ({ notify: mocks.notify }) })); +vi.mock('./useWindowFocused', () => ({ useWindowFocused: () => mocks.focused })); + +function terminal(overrides = {}) { + return { + config: { status: 'Running', nickname: 'Review', label: 'One' }, + xterm: { buffer: { active: { length: 1, getLine: () => ({ translateToString: () => 'Continue?' }) } } }, + ...overrides, + }; +} +const tick = () => act(() => vi.advanceTimersByTime(500)); + +describe('session state polling and notifications', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + mocks.terminals.clear(); + mocks.states.clear(); + mocks.focused = false; + mocks.dnd = false; + mocks.app.dndEnabled = false; + mocks.app.notificationSoundEnabled = true; + mocks.lastOutput.mockReturnValue(undefined); + mocks.classify.mockReturnValue('waiting'); + mocks.setState.mockImplementation((id: string, state: string) => mocks.states.set(id, state)); + mocks.terminals.set('one', terminal()); + }); + afterEach(() => { cleanup(); vi.useRealTimers(); }); + + it('notifies once per waiting episode and rearms after activity', () => { + renderHook(useSessionStateDetection); + tick(); + tick(); + expect(mocks.notify).toHaveBeenCalledExactlyOnceWith('Claude needs your input', 'Review is waiting for your response.'); + expect(mocks.sound).toHaveBeenCalledOnce(); + mocks.classify.mockReturnValue('busy'); + tick(); + mocks.classify.mockReturnValue('waiting'); + tick(); + expect(mocks.notify).toHaveBeenCalledTimes(2); + }); + + it('treats recent output as busy before classifying the settled buffer', () => { + mocks.lastOutput.mockReturnValue(10_000); + renderHook(useSessionStateDetection); + tick(); + expect(mocks.states.get('one')).toBe('busy'); + expect(mocks.classify).not.toHaveBeenCalled(); + expect(mocks.notify).not.toHaveBeenCalled(); + tick(); + expect(mocks.classify).toHaveBeenCalledWith(['Continue?']); + expect(mocks.states.get('one')).toBe('waiting'); + }); + + it('suppresses notifications for the focused active terminal and reads subsequent focus changes', () => { + mocks.focused = true; + const { rerender } = renderHook(useSessionStateDetection); + tick(); + expect(mocks.notify).not.toHaveBeenCalled(); + mocks.classify.mockReturnValue('idle'); + tick(); + mocks.focused = false; + rerender(); + mocks.classify.mockReturnValue('waiting'); + tick(); + expect(mocks.notify).toHaveBeenCalledOnce(); + }); + + it('respects do-not-disturb while still updating session state', () => { + mocks.app.dndEnabled = true; + mocks.dnd = true; + renderHook(useSessionStateDetection); + tick(); + expect(mocks.states.get('one')).toBe('waiting'); + expect(mocks.notify).not.toHaveBeenCalled(); + expect(mocks.sound).not.toHaveBeenCalled(); + }); + + it('supports silent notifications', () => { + mocks.app.notificationSoundEnabled = false; + renderHook(useSessionStateDetection); + tick(); + expect(mocks.notify).toHaveBeenCalledOnce(); + expect(mocks.sound).not.toHaveBeenCalled(); + }); + + it('marks exited sessions stopped and skips shells and script children', () => { + mocks.terminals.set('one', terminal({ config: { status: 'Stopped' } })); + mocks.terminals.set('shell', terminal({ isShellTerminal: true })); + mocks.terminals.set('script', terminal({ scriptParentId: 'one' })); + renderHook(useSessionStateDetection); + tick(); + expect(mocks.setState).toHaveBeenCalledExactlyOnceWith('one', 'stopped'); + expect(mocks.notify).not.toHaveBeenCalled(); + }); + + it('preserves known state when the terminal buffer is not mounted', () => { + mocks.terminals.set('one', terminal({ xterm: null })); + mocks.states.set('one', 'busy'); + renderHook(useSessionStateDetection); + tick(); + expect(mocks.states.get('one')).toBe('busy'); + expect(mocks.classify).not.toHaveBeenCalled(); + }); + + it('stops polling after unmount', () => { + const { unmount } = renderHook(useSessionStateDetection); + unmount(); + tick(); + expect(mocks.setState).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/hooks/useWindowFocused.test.ts b/src/hooks/useWindowFocused.test.ts new file mode 100644 index 0000000..a4ddfff --- /dev/null +++ b/src/hooks/useWindowFocused.test.ts @@ -0,0 +1,59 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWindowFocused } from './useWindowFocused'; + +const win = vi.hoisted(() => ({ isFocused: vi.fn(), onFocusChanged: vi.fn() })); +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => win })); + +describe('useWindowFocused', () => { + beforeEach(() => { + win.isFocused.mockResolvedValue(true); + win.onFocusChanged.mockResolvedValue(vi.fn()); + }); + afterEach(cleanup); + + it('loads initial focus and follows focus events', async () => { + win.isFocused.mockResolvedValue(false); + const { result } = renderHook(useWindowFocused); + await act(async () => {}); + expect(result.current).toBe(false); + act(() => win.onFocusChanged.mock.calls[0][0]({ payload: true })); + expect(result.current).toBe(true); + }); + + it('keeps the safe default when native APIs fail', async () => { + win.isFocused.mockRejectedValue(new Error('unavailable')); + win.onFocusChanged.mockRejectedValue(new Error('unavailable')); + const { result } = renderHook(useWindowFocused); + await act(async () => {}); + expect(result.current).toBe(true); + }); + + it('unregisters a listener that resolves after unmount', async () => { + let resolve!: (fn: () => void) => void; + const unlisten = vi.fn(); + win.onFocusChanged.mockReturnValue(new Promise((r) => { resolve = r; })); + const { unmount } = renderHook(useWindowFocused); + unmount(); + await act(async () => resolve(unlisten)); + expect(unlisten).toHaveBeenCalledOnce(); + }); + + it('unregisters an already installed listener', async () => { + const unlisten = vi.fn(); + win.onFocusChanged.mockResolvedValue(unlisten); + const { unmount } = renderHook(useWindowFocused); + await act(async () => {}); + unmount(); + expect(unlisten).toHaveBeenCalledOnce(); + }); + + it('does not overwrite a newer focus event with a stale initial query', async () => { + let resolve!: (focused: boolean) => void; + win.isFocused.mockReturnValue(new Promise((r) => { resolve = r; })); + const { result } = renderHook(useWindowFocused); + act(() => win.onFocusChanged.mock.calls[0][0]({ payload: false })); + await act(async () => resolve(true)); + expect(result.current).toBe(false); + }); +}); diff --git a/src/hooks/useWindowFocused.ts b/src/hooks/useWindowFocused.ts index 53e000f..73edd46 100644 --- a/src/hooks/useWindowFocused.ts +++ b/src/hooks/useWindowFocused.ts @@ -10,14 +10,24 @@ export function useWindowFocused(): boolean { useEffect(() => { const win = getCurrentWindow(); let unlisten: (() => void) | undefined; + let cancelled = false; + let receivedFocusEvent = false; - win.isFocused().then(setFocused).catch(() => { /* default true */ }); + win.isFocused().then((value) => { + if (!cancelled && !receivedFocusEvent) setFocused(value); + }).catch(() => { /* default true */ }); win - .onFocusChanged(({ payload }) => setFocused(payload)) - .then((fn) => { unlisten = fn; }) + .onFocusChanged(({ payload }) => { + receivedFocusEvent = true; + if (!cancelled) setFocused(payload); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) .catch(() => { /* ignore */ }); - return () => { unlisten?.(); }; + return () => { cancelled = true; unlisten?.(); }; }, []); return focused; diff --git a/src/lib/tabTransfer.test.ts b/src/lib/tabTransfer.test.ts new file mode 100644 index 0000000..4100859 --- /dev/null +++ b/src/lib/tabTransfer.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { installTransferReceiver, requestTransfer, routeTabDrop } from './tabTransfer'; +import type { TerminalConfig } from '../store/terminalStore'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), emit: vi.fn(), listen: vi.fn(), windows: vi.fn(), + detach: vi.fn(), focus: vi.fn(), create: vi.fn(), +})); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('@tauri-apps/api/event', () => ({ emit: mocks.emit, listen: mocks.listen })); +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + getAllWebviewWindows: mocks.windows, + getCurrentWebviewWindow: () => ({ setFocus: mocks.focus }), + WebviewWindow: class { constructor(...args: unknown[]) { mocks.create(...args); } once = vi.fn(); }, +})); +vi.mock('../store/terminalStore', () => ({ useTerminalStore: { getState: () => ({ detachTerminals: mocks.detach }) } })); +vi.mock('./errorReporter', () => ({ reportError: vi.fn() })); +import { reportError } from './errorReporter'; + +const transfer = 'ct://tab-transfer'; +const done = 'ct://tab-transfer-done'; +const windowAt = (label: string, x = 0) => ({ + label, outerPosition: vi.fn().mockResolvedValue({ x, y: 0 }), + outerSize: vi.fn().mockResolvedValue({ width: 100, height: 100 }), +}); +const config = { id: 'one', label: 'One' } as TerminalConfig; + +describe('tab transfer protocol', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.invoke.mockResolvedValue([50, 50]); + mocks.emit.mockResolvedValue(undefined); + mocks.focus.mockResolvedValue(undefined); + mocks.windows.mockResolvedValue([]); + mocks.listen.mockResolvedValue(vi.fn()); + }); + + it('does no desktop work for an empty selection', async () => { + await routeTabDrop([], 'main'); + await requestTransfer('other', [], 'main'); + expect(mocks.invoke).not.toHaveBeenCalled(); + expect(mocks.emit).not.toHaveBeenCalled(); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('transfers to a hit window while excluding the source and drag overlay', async () => { + mocks.windows.mockResolvedValue([windowAt('main'), windowAt('drag-preview'), windowAt('target')]); + await routeTabDrop(['one'], 'main'); + expect(mocks.emit).toHaveBeenCalledWith(transfer, { targetLabel: 'target', ids: ['one'], sourceLabel: 'main' }); + expect(mocks.detach).toHaveBeenCalledWith(['one']); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('skips a window that closes during hit testing', async () => { + const closed = windowAt('closed'); + closed.outerPosition.mockRejectedValue(new Error('closed')); + mocks.windows.mockResolvedValue([closed, windowAt('target')]); + await routeTabDrop(['one'], 'main'); + expect(mocks.emit).toHaveBeenCalledWith(transfer, expect.objectContaining({ targetLabel: 'target' })); + }); + + it('opens a detached view outside other windows without killing the PTY', async () => { + mocks.windows.mockResolvedValue([windowAt('target', 500)]); + await routeTabDrop(['one', 'two'], 'main'); + expect(mocks.create).toHaveBeenCalledWith(expect.stringMatching(/^detached-/), expect.objectContaining({ + url: 'index.html?mode=detached&ids=one%2Ctwo', + })); + expect(mocks.detach).toHaveBeenCalledWith(['one', 'two']); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + }); + + it('keeps source tabs when the cursor lookup fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.invoke.mockRejectedValue(new Error('cursor unavailable')); + await routeTabDrop(['one'], 'main'); + expect(mocks.detach).not.toHaveBeenCalled(); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('keeps source tabs if emitting the handoff fails', async () => { + mocks.windows.mockResolvedValue([windowAt('target')]); + mocks.emit.mockRejectedValue(new Error('event failed')); + await expect(routeTabDrop(['one'], 'main')).rejects.toThrow('event failed'); + expect(mocks.detach).not.toHaveBeenCalled(); + }); + + function receive() { + const adopt = vi.fn(); + const detach = vi.fn(); + const dispose = installTransferReceiver('target', adopt, detach); + const handler = (name: string) => mocks.listen.mock.calls.find(([event]) => event === name)![1]; + return { adopt, detach, dispose, handler }; + } + + it('ignores transfers for other windows and self-originated requests', async () => { + const { handler, adopt } = receive(); + await handler(transfer)({ payload: { targetLabel: 'other', sourceLabel: 'main', ids: ['one'] } }); + await handler(transfer)({ payload: { targetLabel: 'target', sourceLabel: 'target', ids: ['one'] } }); + expect(mocks.invoke).not.toHaveBeenCalled(); + expect(adopt).not.toHaveBeenCalled(); + }); + + it('adopts an existing PTY with scrollback before acknowledging and focusing', async () => { + mocks.invoke.mockResolvedValueOnce([config]).mockResolvedValueOnce('saved output'); + const { handler, adopt } = receive(); + await handler(transfer)({ payload: { targetLabel: 'target', sourceLabel: 'main', ids: ['one'] } }); + expect(adopt).toHaveBeenCalledWith(config, 'saved output'); + expect(mocks.invoke).toHaveBeenNthCalledWith(2, 'get_session_log', { terminalId: 'one' }); + expect(mocks.emit).toHaveBeenCalledWith(done, { ids: ['one'], byLabel: 'target' }); + expect(adopt.mock.invocationCallOrder[0]).toBeLessThan(mocks.emit.mock.invocationCallOrder[0]); + expect(mocks.focus).toHaveBeenCalledOnce(); + }); + + it('can adopt without scrollback when the session log is unavailable', async () => { + mocks.invoke.mockResolvedValueOnce([config]).mockRejectedValueOnce(new Error('no log')); + const { handler, adopt } = receive(); + await handler(transfer)({ payload: { targetLabel: 'target', sourceLabel: 'main', ids: ['one'] } }); + expect(adopt).toHaveBeenCalledWith(config, undefined); + expect(mocks.emit).toHaveBeenCalledOnce(); + }); + + it('reports failed adoption without acknowledging it', async () => { + mocks.invoke.mockRejectedValue(new Error('backend unavailable')); + const { handler, adopt } = receive(); + await handler(transfer)({ payload: { targetLabel: 'target', sourceLabel: 'main', ids: ['one'] } }); + expect(adopt).not.toHaveBeenCalled(); + expect(mocks.emit).not.toHaveBeenCalled(); + expect(reportError).toHaveBeenCalledWith('tab_transfer_adopt', 'backend unavailable'); + }); + + it('releases tabs adopted elsewhere but retains its own adopted tabs', () => { + const { handler, detach } = receive(); + handler(done)({ payload: { ids: ['one'], byLabel: 'target' } }); + expect(detach).not.toHaveBeenCalled(); + handler(done)({ payload: { ids: ['one'], byLabel: 'other' } }); + expect(detach).toHaveBeenCalledWith(['one']); + }); + + it('cleans up event registrations even when they resolve after disposal', async () => { + const resolvers: Array<(fn: () => void) => void> = []; + mocks.listen.mockImplementation(() => new Promise((resolve) => resolvers.push(resolve))); + const { dispose } = receive(); + dispose(); + const unlisteners = [vi.fn(), vi.fn()]; + resolvers.forEach((resolve, i) => resolve(unlisteners[i])); + await Promise.resolve(); + unlisteners.forEach((fn) => expect(fn).toHaveBeenCalledOnce()); + }); +}); diff --git a/src/lib/windowLayout.test.ts b/src/lib/windowLayout.test.ts new file mode 100644 index 0000000..8b0f919 --- /dev/null +++ b/src/lib/windowLayout.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { currentGeometry, getDetachedEntries, keyOf, removeEntry, upsertEntry } from './windowLayout'; + +const win = vi.hoisted(() => ({ outerPosition: vi.fn(), outerSize: vi.fn() })); +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => win })); +const key = 'ct-window-layout'; + +describe('window layout persistence', () => { + beforeEach(() => localStorage.clear()); + + it('uses session identity with a working-directory fallback', () => { + expect(keyOf({ claude_session_id: 's1', working_directory: '/repo' })).toBe('sid:s1'); + expect(keyOf({ claude_session_id: null, working_directory: '/repo' })).toBe('cwd:/repo'); + }); + + it('updates and removes one window without losing others; excludes main', () => { + upsertEntry('main', { sessionKeys: ['main'] }); + upsertEntry('one', { sessionKeys: ['old'] }); + upsertEntry('two', { sessionKeys: ['two'] }); + upsertEntry('one', { sessionKeys: ['new'] }); + expect(getDetachedEntries()).toEqual([ + { label: 'one', entry: { sessionKeys: ['new'] } }, + { label: 'two', entry: { sessionKeys: ['two'] } }, + ]); + removeEntry('one'); + removeEntry('missing'); + expect(getDetachedEntries()).toEqual([{ label: 'two', entry: { sessionKeys: ['two'] } }]); + }); + + it.each(['{broken', 'null', '42', '"text"', '[]'])('recovers from invalid stored layout %s', (value) => { + localStorage.setItem(key, value); + expect(getDetachedEntries()).toEqual([]); + expect(() => removeEntry('missing')).not.toThrow(); + upsertEntry('one', { sessionKeys: ['s1'] }); + expect(getDetachedEntries()).toEqual([{ label: 'one', entry: { sessionKeys: ['s1'] } }]); + }); + + it('drops malformed entries while preserving valid sessions and ignoring invalid geometry', () => { + localStorage.setItem(key, JSON.stringify({ + good: { sessionKeys: ['s1'], geometry: { x: -100, y: 0, w: 800, h: 600 } }, + bad: null, wrongKeys: { sessionKeys: [1] }, + badGeometry: { sessionKeys: ['s2'], geometry: { x: 0, y: 0, w: -1, h: 600 } }, + })); + expect(getDetachedEntries()).toEqual([ + { label: 'good', entry: { sessionKeys: ['s1'], geometry: { x: -100, y: 0, w: 800, h: 600 } } }, + { label: 'badGeometry', entry: { sessionKeys: ['s2'] } }, + ]); + }); + + it('tolerates unavailable storage and quota failures', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('denied'); }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota'); }); + expect(getDetachedEntries()).toEqual([]); + expect(() => upsertEntry('one', { sessionKeys: [] })).not.toThrow(); + }); + + it('reads physical geometry including negative monitor coordinates', async () => { + win.outerPosition.mockResolvedValue({ x: -1920, y: 20 }); + win.outerSize.mockResolvedValue({ width: 1000, height: 680 }); + expect(await currentGeometry()).toEqual({ x: -1920, y: 20, w: 1000, h: 680 }); + }); + + it('tolerates a window closing during geometry lookup', async () => { + win.outerPosition.mockRejectedValue(new Error('closed')); + expect(await currentGeometry()).toBeUndefined(); + }); +}); diff --git a/src/lib/windowLayout.ts b/src/lib/windowLayout.ts index 9638ac5..7078bef 100644 --- a/src/lib/windowLayout.ts +++ b/src/lib/windowLayout.ts @@ -30,7 +30,22 @@ export function keyOf(cfg: Pick typeof key === 'string')) continue; + const entry: WindowEntry = { sessionKeys }; + if (geometry && typeof geometry === 'object' && + [geometry.x, geometry.y, geometry.w, geometry.h].every(Number.isFinite) && + geometry.w > 0 && geometry.h > 0) { + entry.geometry = { x: geometry.x, y: geometry.y, w: geometry.w, h: geometry.h }; + } + layout[label] = entry; + } + return layout; } catch { return {}; } diff --git a/src/store/pasteStore.test.ts b/src/store/pasteStore.test.ts new file mode 100644 index 0000000..f784817 --- /dev/null +++ b/src/store/pasteStore.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { usePasteStore, type PasteEntry } from './pasteStore'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); +const entry = (name: string): PasteEntry => ({ + file_name: name, relative_path: `.pastes/${name}`, absolute_path: `/repo/.pastes/${name}`, + size_bytes: 300, created_at: '2026-09-10T00:00:00Z', detected_kind: 'text', +}); +const store = () => usePasteStore.getState(); + +describe('paste history', () => { + beforeEach(() => usePasteStore.setState({ byTerminal: new Map() })); + + it('keeps newest first, bounds previews and isolates terminal histories', () => { + store().add('one', entry('old'), 'old'); + store().add('two', entry('other'), 'other'); + store().add('one', entry('new'), 'x'.repeat(300)); + expect(store().list('one').map((e) => e.file_name)).toEqual(['new', 'old']); + expect(store().list('one')[0].preview).toBe('x'.repeat(200)); + expect(store().list('two')[0].preview).toBe('other'); + expect(store().list('unknown')).toEqual([]); + }); + + it('evicts the oldest paste after fifty entries without mutating previous state', () => { + store().add('one', entry('0'), 'first'); + const previous = store().byTerminal; + for (let i = 1; i <= 50; i++) store().add('one', entry(String(i)), 'content'); + expect(store().list('one')).toHaveLength(50); + expect(store().list('one')[0].file_name).toBe('50'); + expect(store().list('one')[49].file_name).toBe('1'); + expect(previous.get('one')).toHaveLength(1); + }); + + it('removes and clears only the requested terminal history', () => { + store().add('one', entry('same'), 'one'); + store().add('two', entry('same'), 'two'); + store().remove('one', 'same'); + expect(store().list('one')).toEqual([]); + expect(store().list('two')).toHaveLength(1); + store().clearForTerminal('one'); + expect(store().byTerminal.has('one')).toBe(false); + expect(store().list('two')).toHaveLength(1); + }); + + it('restores disk metadata without inventing content previews', async () => { + vi.mocked(invoke).mockResolvedValue([entry('saved')]); + store().add('other', entry('keep'), 'keep'); + await store().hydrateFromDisk('one'); + expect(invoke).toHaveBeenCalledWith('list_pastes', { terminalId: 'one' }); + expect(store().list('one')).toEqual([{ ...entry('saved'), preview: '' }]); + expect(store().list('other')[0].preview).toBe('keep'); + }); + + it('retains existing history if the disk lookup fails', async () => { + store().add('one', entry('keep'), 'keep'); + vi.mocked(invoke).mockRejectedValue(new Error('directory missing')); + await expect(store().hydrateFromDisk('one')).resolves.toBeUndefined(); + expect(store().list('one')[0].preview).toBe('keep'); + }); +}); diff --git a/workers/ct-analytics/vitest.config.ts b/workers/ct-analytics/vitest.config.ts new file mode 100644 index 0000000..4f55a83 --- /dev/null +++ b/workers/ct-analytics/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // Keep the worker independent of the frontend's PostCSS dependencies. + css: { postcss: { plugins: [] } }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});