agent-ecosystem/.planning/codebase/TESTING.md
matt ae4833e310 feat(planning): add project planning documents and configuration for SSH multi-context workspaces
- Introduced new planning files including PROJECT.md, REQUIREMENTS.md, ROADMAP.md, and STATE.md to outline the vision and requirements for SSH multi-context workspaces.
- Added ARCHITECTURE.md and CONCERNS.md to detail the codebase structure and address technical debt, known bugs, and security considerations.
- Created CONVENTIONS.md to establish coding standards and practices for the project.
- Updated .gitignore to exclude demo files and added configuration for planning tools.

This commit lays the groundwork for enhancing SSH functionality and user experience in managing multiple workspaces.
2026-02-12 09:37:58 +09:00

13 KiB

Testing Patterns

Analysis Date: 2026-02-12

Test Framework

Runner:

  • Vitest 3.1.4
  • Config: /Users/bskim/claude-devtools/vitest.config.ts

Environment:

  • happy-dom 17.4.6 (DOM simulation)
  • Globals enabled (describe, it, expect available without imports)

Assertion Library:

  • Vitest built-in assertions (Chai-compatible API)

Run Commands:

pnpm test                      # Run all tests
pnpm test:watch                # Watch mode
pnpm test:coverage             # Coverage report
pnpm test:coverage:critical    # Critical path coverage only
pnpm test:chunks               # Chunk building tests
pnpm test:semantic             # Semantic step extraction
pnpm test:noise                # Noise filtering tests
pnpm test:task-filtering       # Task tool filtering

Test File Organization

Location: Co-located in separate test/ directory, mirroring source structure.

Naming: *.test.ts - matches source file name exactly.

Structure:

test/
├── main/
│   ├── ipc/                   # IPC handler tests
│   │   ├── configValidation.test.ts
│   │   └── guards.test.ts
│   ├── services/              # Service layer tests
│   │   ├── analysis/          # ChunkBuilder, etc.
│   │   ├── discovery/         # ProjectPathResolver, SessionSearcher
│   │   ├── infrastructure/    # FileWatcher
│   │   └── parsing/           # MessageClassifier, SessionParser
│   └── utils/                 # Utility function tests
│       ├── jsonl.test.ts
│       ├── pathDecoder.test.ts
│       ├── pathValidation.test.ts
│       ├── regexValidation.test.ts
│       └── tokenizer.test.ts
├── renderer/
│   ├── hooks/                 # Hook tests
│   │   ├── navigationUtils.test.ts
│   │   ├── useAutoScrollBottom.test.ts
│   │   ├── useSearchContextNavigation.test.ts
│   │   └── useVisibleAIGroup.test.ts
│   ├── store/                 # Zustand store slice tests
│   │   ├── notificationSlice.test.ts
│   │   ├── paneSlice.test.ts
│   │   ├── pathResolution.test.ts
│   │   ├── sessionSlice.test.ts
│   │   ├── tabSlice.test.ts
│   │   └── tabUISlice.test.ts
│   └── utils/                 # Renderer utilities
│       ├── claudeMdTracker.test.ts
│       ├── dateGrouping.test.ts
│       ├── formatters.test.ts
│       └── pathUtils.test.ts
├── shared/
│   └── utils/                 # Shared utilities
│       ├── markdownSearchRendererAlignment.test.ts
│       ├── markdownTextSearch.test.ts
│       ├── modelParser.test.ts
│       └── tokenFormatting.test.ts
├── mocks/                     # Test fixtures and mocks
│   └── electronAPI.ts         # Mock window.electronAPI
└── setup.ts                   # Global test setup

Test Structure

Suite Organization:

import { describe, expect, it } from 'vitest';

import { ChunkBuilder } from '../../../../src/main/services/analysis/ChunkBuilder';
import { isAIChunk, isUserChunk } from '../../../../src/main/types';

describe('ChunkBuilder', () => {
  const builder = new ChunkBuilder();

  describe('buildChunks', () => {
    it('should return empty array for empty input', () => {
      const chunks = builder.buildChunks([]);
      expect(chunks).toEqual([]);
    });

    it('should filter out sidechain messages', () => {
      const messages = [
        createMessage({ type: 'user', isSidechain: false }),
        createMessage({ type: 'assistant', isSidechain: true }),
      ];

      const chunks = builder.buildChunks(messages);
      expect(chunks).toHaveLength(1);
      expect(isUserChunk(chunks[0])).toBe(true);
    });
  });

  describe('UserChunk creation', () => {
    // Nested describe for logical grouping
  });
});

Patterns:

  • Top-level describe per class/module
  • Nested describe per method/function
  • Descriptive it statements ("should do X when Y")
  • Arrange-Act-Assert pattern

Fixtures and Factories

Test Data: Helper functions to create test objects:

/**
 * Creates a minimal ParsedMessage for testing.
 */
function createMessage(overrides: Partial<ParsedMessage>): ParsedMessage {
  return {
    uuid: `msg-${Math.random().toString(36).slice(2, 11)}`,
    parentUuid: null,
    type: 'user',
    timestamp: new Date(),
    content: '',
    isSidechain: false,
    isMeta: false,
    toolCalls: [],
    toolResults: [],
    ...overrides,
  };
}

/**
 * Creates a minimal Process (subagent) for testing.
 */
function createSubagent(overrides: Partial<Process>): Process {
  return {
    id: `agent-${Math.random().toString(36).slice(2, 11)}`,
    filePath: '/path/to/agent.jsonl',
    parentTaskId: 'task-1',
    description: 'Test subagent',
    startTime: new Date(),
    endTime: new Date(),
    durationMs: 1000,
    isOngoing: false,
    messages: [],
    metrics: {
      inputTokens: 100,
      outputTokens: 50,
      cacheReadTokens: 0,
      cacheCreationTokens: 0,
      totalTokens: 150,
      messageCount: 2,
      durationMs: 1000,
    },
    ...overrides,
  };
}

Location: Defined in test files (not centralized) for visibility and simplicity.

Mocking

Framework: Vitest built-in mocking (vi.fn(), vi.mock(), vi.spyOn())

ElectronAPI Mock Pattern:

// test/mocks/electronAPI.ts
export interface MockElectronAPI {
  getProjects: ReturnType<typeof vi.fn>;
  getSessions: ReturnType<typeof vi.fn>;
  getSessionsPaginated: ReturnType<typeof vi.fn>;
  // ... all IPC methods
}

export function installMockElectronAPI(): MockElectronAPI {
  const mock: MockElectronAPI = {
    getProjects: vi.fn(),
    getSessions: vi.fn(),
    // ...
  };

  vi.stubGlobal('window', {
    electronAPI: mock,
  });

  return mock;
}

// Usage in tests:
import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI';

describe('sessionSlice', () => {
  let mockAPI: MockElectronAPI;

  beforeEach(() => {
    mockAPI = installMockElectronAPI();
  });

  it('should fetch sessions', async () => {
    mockAPI.getSessions.mockResolvedValue([/* data */]);
    // Test implementation
  });
});

Console Mocking: Automatic via test/setup.ts - all tests fail if unexpected console.error/warn occurs:

// test/setup.ts
beforeEach(() => {
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
  warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
  const unexpectedErrors = errorSpy.mock.calls.map(formatConsoleCall);
  const unexpectedWarnings = warnSpy.mock.calls.map(formatConsoleCall);

  errorSpy.mockRestore();
  warnSpy.mockRestore();

  expect(unexpectedErrors, `Unexpected console.error calls:\n${unexpectedErrors.join('\n')}`).toEqual([]);
  expect(unexpectedWarnings, `Unexpected console.warn calls:\n${unexpectedWarnings.join('\n')}`).toEqual([]);
});

What to Mock:

  • window.electronAPI - Always mock in renderer tests
  • File system operations - Mock when testing logic, not I/O
  • External dependencies - Mock when testing integration points

What NOT to Mock:

  • Internal utilities (test them directly)
  • Type guards (pure functions)
  • Formatters and transformers (integration is valuable)

Coverage

Requirements: No enforced minimum (quality over coverage)

Provider: v8 (native V8 coverage)

Reporters:

  • text - Terminal output
  • json - Machine-readable
  • html - Interactive browser report

View Coverage:

pnpm test:coverage          # Full coverage
pnpm test:coverage:critical # Critical paths only

Includes:

  • src/**/*.ts
  • src/**/*.tsx

Excludes:

  • src/**/*.d.ts (type definitions)
  • src/main/index.ts (entry point)
  • src/preload/index.ts (entry point)

Critical Path Config: Separate config at /Users/bskim/claude-devtools/vitest.critical.config.ts focuses on:

  • Chunk building (ChunkBuilder.test.ts)
  • Message classification (MessageClassifier.test.ts)
  • Session parsing (SessionParser.test.ts)

Test Types

Unit Tests: Test individual functions, classes, and utilities in isolation.

Example:

describe('pathDecoder', () => {
  describe('encodePath', () => {
    it('should encode absolute POSIX paths', () => {
      expect(encodePath('/Users/username/project')).toBe('-Users-username-project');
    });

    it('should encode Windows paths', () => {
      expect(encodePath('C:\\Users\\username\\project')).toBe('-C:-Users-username-project');
    });
  });
});

Integration Tests: Test interactions between modules (e.g., ChunkBuilder + MessageClassifier).

Example:

it('should link subagents to AIChunks', () => {
  const messages = [
    createMessage({ type: 'assistant', toolCalls: [{ isTask: true, id: 'task-1' }] }),
  ];
  const subagents = [
    createSubagent({ parentTaskId: 'task-1' }),
  ];

  const chunks = builder.buildChunks(messages, subagents);
  const aiChunk = chunks.find(isAIChunk);

  expect(aiChunk?.subagents).toHaveLength(1);
});

Store Tests: Test Zustand slice behavior (state updates, async actions).

Example:

describe('sessionSlice', () => {
  it('should update sessions on fetch', async () => {
    mockAPI.getSessions.mockResolvedValue([
      { id: 'session-1', createdAt: '2024-01-15T10:00:00Z' },
    ]);

    await store.getState().fetchSessions('project-1');

    expect(store.getState().sessions).toHaveLength(1);
    expect(store.getState().sessionsLoading).toBe(false);
  });
});

E2E Tests: Not currently implemented.

Common Patterns

Async Testing:

it('should handle async operations', async () => {
  mockAPI.getSessions.mockResolvedValue([/* data */]);

  await store.getState().fetchSessions('project-1');

  expect(store.getState().sessions).toHaveLength(1);
});

Error Testing:

it('should handle fetch error', async () => {
  mockAPI.getSessions.mockRejectedValue(new Error('Network error'));

  await store.getState().fetchSessions('project-1');

  expect(store.getState().sessionsError).toBe('Network error');
  expect(store.getState().sessionsLoading).toBe(false);
});

Timing/Debounce Testing:

it('should debounce rapid calls', async () => {
  vi.useFakeTimers();

  const callback = vi.fn();
  const debounced = debounce(callback, 100);

  debounced();
  debounced();
  debounced();

  vi.advanceTimersByTime(100);

  expect(callback).toHaveBeenCalledTimes(1);

  vi.useRealTimers();
});

State Transitions:

it('should set loading state during fetch', async () => {
  mockAPI.getSessions.mockImplementation(
    () => new Promise(resolve => setTimeout(() => resolve([]), 100))
  );

  const fetchPromise = store.getState().fetchSessions('project-1');
  expect(store.getState().sessionsLoading).toBe(true);

  await fetchPromise;
  expect(store.getState().sessionsLoading).toBe(false);
});

Test-Specific ESLint Relaxations

Tests use relaxed TypeScript rules (from eslint.config.js):

// Relaxed for tests
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-floating-promises': 'off',

Rationale: Test code prioritizes readability and flexibility over type safety.

Setup and Teardown

Global Setup: /Users/bskim/claude-devtools/test/setup.ts runs before each test file:

  • Mocks process.env.HOME
  • Installs console spies (fail on unexpected errors/warnings)

Per-Suite Setup:

describe('MyService', () => {
  let mockAPI: MockElectronAPI;
  let store: TestStore;

  beforeEach(() => {
    mockAPI = installMockElectronAPI();
    store = createTestStore();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });
});

Path Aliases in Tests

Vitest config mirrors TypeScript paths:

resolve: {
  alias: {
    '@shared': resolve(__dirname, 'src/shared'),
    '@main': resolve(__dirname, 'src/main'),
    '@renderer': resolve(__dirname, 'src/renderer'),
  },
}

Use same aliases as source code:

import { ChunkBuilder } from '@main/services';
import { useStore } from '@renderer/store';
import { formatTokens } from '@shared/utils/tokenFormatting';

Best Practices

Test Independence: Each test should run independently - no shared state between tests.

Descriptive Names:

// Good
it('should return empty array when no messages provided', () => {});

// Bad
it('returns []', () => {});

Single Assertion Focus: Prefer multiple small tests over one large test with many assertions.

Factory Functions: Use factory functions for test data creation - more maintainable than inline objects.

Mock Minimally: Only mock external boundaries (IPC, file system) - test real logic.

Console Discipline: If a test legitimately logs errors, it will fail. Either:

  1. Fix the code to not log errors
  2. Mock the specific logger call
  3. Adjust test expectations

Testing analysis: 2026-02-12