Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions docs/testing-audit.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions src/hooks/useNowTick.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 46 additions & 0 deletions src/hooks/usePreventWebviewReload.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, object>() }));
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);
}
});
});
130 changes: 130 additions & 0 deletions src/hooks/useSessionStateDetection.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>(), states: new Map<string, string>(),
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);
});
});
59 changes: 59 additions & 0 deletions src/hooks/useWindowFocused.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 14 additions & 4 deletions src/hooks/useWindowFocused.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading