Testing Guide

This document covers the test infrastructure, conventions, and execution workflow for the Crucible monorepo.

Quick Reference

# Run all tests across the monorepo
pnpm test

# Run tests for a single package
pnpm --filter @crucible/catalog test
pnpm --filter @crucible/demo-dashboard test
pnpm --filter web-client test

# Watch mode (re-runs on file changes)
pnpm --filter @crucible/catalog test:watch
pnpm --filter @crucible/demo-dashboard test:watch
pnpm --filter web-client test:watch

# Run a specific test file
cd apps/demo-dashboard && npx vitest run src/__tests__/engine.test.ts

Stack

Tool Version Purpose
Vitest 3.2.4 Test runner and assertion library
Nx 21.6.4 Monorepo task orchestration with caching
@testing-library/react 16.3.2 React component testing (web-client)
@testing-library/jest-dom 6.9.1 DOM assertion matchers (web-client)
jsdom 28.1.0 Browser environment simulation (web-client)

Project Structure

Tests live in __tests__/ directories adjacent to the source code they cover:

packages/catalog/src/
  adapters/__tests__/
    runbook-parser.test.ts          # Markdown/YAML parsing
  models/__tests__/
    types.test.ts                   # Zod schema validation
    runbook-types.test.ts           # Runbook Zod schemas
  service/__tests__/
    catalog-service.test.ts         # Service layer + query methods
  validation/__tests__/
    scenario-validator.test.ts      # DAG validation + cycle detection

apps/demo-dashboard/src/
  __tests__/
    engine.test.ts                  # ScenarioEngine (core execution)
    websocket.test.ts               # WebSocket message handling

apps/web-client/src/
  app/scenarios/__tests__/
    page.test.tsx                   # ScenariosPage (search, filter, dialog)
  components/scenario-editor/__tests__/
    scenario-editor-tab.test.tsx    # Prototype pollution filtering
    tag-input.test.tsx              # TagInput keyboard/click behavior
  hooks/__tests__/
    useWebSocket.test.ts            # WebSocket hook lifecycle
  lib/__tests__/
    utils.test.ts                   # cn() utility
  store/__tests__/
    useCatalogStore.test.ts         # Zustand store operations

Vitest Configuration

Each package has its own vitest.config.ts. All share globals: true so describe, it, expect, vi, etc. are available without imports.

Server packages (catalog, demo-dashboard)

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
  },
});

Web client (web-client)

Uses jsdom for DOM simulation, the React plugin for JSX transforms, and a setup file for Next.js mocks:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    css: true,
  },
});

The @ alias mirrors the Next.js tsconfig.json path so imports like @/store/useCatalogStore resolve correctly in tests.

Setup file (web-client/vitest.setup.ts)

Registers @testing-library/jest-dom matchers and mocks Next.js modules that don’t exist in jsdom:

import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';

vi.mock('next/navigation', () => ({
  useRouter: () => ({
    push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(),
    back: vi.fn(), forward: vi.fn(), refresh: vi.fn(),
  }),
  useSearchParams: () => new URLSearchParams(),
  usePathname: () => '/',
  useParams: () => ({}),
  redirect: vi.fn(),
  notFound: vi.fn(),
}));

vi.mock('next/image', () => ({
  default: (props: Record<string, unknown>) => props,
}));

Nx Caching

pnpm test delegates to nx run-many --target=test --all. Nx caches test results based on source file hashes. If no files changed, tests replay from cache instantly.

To force a fresh run (bypass cache):

npx nx run-many --target=test --all --skip-nx-cache

If you see a stale failure after fixing a file, this is usually an Nx cache artifact. Running the package directly (npx vitest run inside the package directory) updates the cache.

Writing Tests

Naming Conventions

  • Test files: <module-name>.test.ts or <component-name>.test.tsx
  • Test directories: __tests__/ adjacent to the source module
  • Describe blocks: match the module or class name
  • Test names: describe the behavior, not the method ('skips step when condition is not met' not 'test evaluateWhen')

Mocking Patterns

Global fetch (server packages)

const mockFetch = vi.fn();
global.fetch = mockFetch as any;

function mockResponse(status: number, body: unknown, headers: Record<string, string> = {}) {
  const headerMap = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
  return {
    status,
    ok: status >= 200 && status < 300,
    headers: {
      get: (name: string) => headerMap.get(name.toLowerCase()) ?? null,
      forEach: (cb: (v: string, k: string) => void) => headerMap.forEach((v, k) => cb(v, k)),
    },
    json: () => Promise.resolve(body),
    text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
  };
}

Zustand store (web-client)

Zustand stores are testable without React rendering. Import the store, call actions directly, and assert state:

import { useCatalogStore } from '../useCatalogStore';

beforeEach(() => {
  useCatalogStore.setState({ scenarios: [], error: null, isLoading: false });
});

it('fetches and sets scenarios', async () => {
  mockFetch.mockResolvedValueOnce(mockJsonResponse(200, [{ id: 'a', name: 'Alpha', steps: [] }]));
  await useCatalogStore.getState().fetchScenarios();
  expect(useCatalogStore.getState().scenarios).toHaveLength(1);
});

React components (web-client)

Use @testing-library/react with vi.mock() to isolate dependencies:

import { render, screen, fireEvent } from '@testing-library/react';

// Mock child components to keep tests focused
vi.mock('../sub-component', () => ({
  SubComponent: ({ data }: any) => <div data-testid="sub">{data}</div>,
}));

// Mock the store
vi.mock('@/store/useCatalogStore', () => ({
  useCatalogStore: () => ({ scenarios: mockScenarios, fetchScenarios: vi.fn() }),
}));

React hooks (web-client)

Use renderHook from @testing-library/react:

import { renderHook, act } from '@testing-library/react';

it('connects on mount', () => {
  renderHook(() => useWebSocket());
  expect(MockWebSocket.instances).toHaveLength(1);
});

Fake timers (engine async tests)

The ScenarioEngine uses timers for delays, reconnects, and cleanup. Use Vitest’s fake timer API:

beforeEach(() => {
  vi.useFakeTimers({ shouldAdvanceTime: true });
});

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

// In tests:
await vi.advanceTimersByTimeAsync(3500);

The shouldAdvanceTime: true option allows Date.now() to advance with fake timers, which the engine relies on for timestamps.

EventEmitter-based testing (ScenarioEngine)

The engine emits events for lifecycle changes. Use a promise-based helper to wait for them:

function waitForEvent(engine: ScenarioEngine, event: string, timeout = 5000): Promise<any> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${event}`)), timeout);
    engine.once(event, (data: any) => {
      clearTimeout(timer);
      resolve(data);
    });
  });
}

// Usage:
const done = waitForEvent(engine, 'execution:completed');
await engine.startScenario('my-scenario');
const execution = await done;
expect(execution.status).toBe('completed');

Deferred fetch pattern (concurrency/pause tests)

For tests that need to control when a fetch resolves, use the deferred resolver pattern:

let resolvers: Array<(v: any) => void> = [];
mockFetch.mockImplementation(() =>
  new Promise((resolve) => {
    resolvers.push(() => resolve(mockResponse(200, 'ok')));
  }),
);

// Start execution — fetch is now in-flight
await engine.startScenario('test');
await vi.advanceTimersByTimeAsync(10);

// Manually resolve the fetch when ready
resolvers[0](undefined);

This pattern is essential for testing pause/resume, cancel, and concurrency behavior where you need precise control over when async operations complete.

Test Coverage by Domain

Catalog (packages/catalog) — 73 tests

Area Tests What’s covered
Zod schemas 30 ScenarioSchema, RequestSchema, ExecutionConfigSchema, ExtractRuleSchema, RunbookFrontmatterSchema, RunbookStepSchema, enums
Scenario validator 11 DAG cycle detection, missing references, template variable warnings
Runbook parser 20 Frontmatter parsing, title extraction, slugify, step/substep parsing, phase headings
CatalogService 12 Constructor loading, schema validation, file I/O, query methods

Demo Dashboard (apps/demo-dashboard) — 68 tests

Area Tests What’s covered
Assertions 11 status, blocked, bodyContains, bodyNotContains, headerPresent, headerEquals, multi-assertion
Extract rules 5 body JSON path, header, status, missing path, multi-source
Template variables 4 ,, ``, all-location resolution
Conditionals 5 when.succeeded (positive/negative), when.status, missing ref step
Context resolution 1 Variable extraction and cross-step resolution
Retries 2 Success on last attempt, exhausted retries
Concurrency 2 Semaphore limit, queued execution start
Pause/Resume 2 Pause preserves state, resume completes execution
Cancel 3 Running cancel, paused cancel, AbortSignal propagation
Restart 2 New execution with parent ID, cancel-before-restart
Cleanup 2 TTL eviction, max-count eviction
Global controls 6 pauseAll, resumeAll, cancelAll (count + state verification)
Deadlock detection 4 Circular, self-dep, deep chain, valid chain
Step execution 5 Iterations, delay timing, unknown scenario, destroy(), abort propagation
WebSocket 9 Invalid JSON, unknown commands, missing payloads, broadcast filtering
Assessment 5 100%/mixed/0% scoring, skipped steps, simulation-only guard

Web Client (apps/web-client) — 48 tests

Area Tests What’s covered
useCatalogStore 15 Initial state, fetch, error handling, execution CRUD, simulation start, pause
useWebSocket 7 Connect, open/close state, message dispatch, malformed JSON, reconnect, cleanup
cn() utility 5 Merging, conditionals, Tailwind conflicts, empty inputs, arrays
ScenarioEditorTab 4 Prototype pollution filtering (__proto__, constructor, prototype), safe key preservation
TagInput 7 Enter/blur add, trim, duplicates, remove, backspace edit, empty prevention
ScenariosPage 10 Mount fetch, render, search (name/category/tag), empty state, skeletons, dialog, simulate/assess buttons

Total: 189 tests across 13 test files.

Troubleshooting

Tests pass individually but fail via pnpm test

Nx caches results per-project. If a previous run failed and you’ve since fixed the code, the cache may still hold the failure. Run with --skip-nx-cache or run the package directly to update the cache.

web-client tests fail with “Cannot find module” for @/... imports

The @ path alias must be configured in both tsconfig.json and vitest.config.ts. Ensure the vitest config has:

resolve: {
  alias: {
    '@': path.resolve(__dirname, './src'),
  },
},

next/navigation or next/image errors in tests

These are mocked in vitest.setup.ts. If you see errors about these modules, verify the setup file is listed in vitest config:

test: {
  setupFiles: ['./vitest.setup.ts'],
}

Fake timer tests hang or timeout

When using vi.useFakeTimers(), always call vi.useRealTimers() in afterEach. For async operations with fake timers, use vi.advanceTimersByTimeAsync() (not vi.advanceTimersByTime()) and await the result.

--passWithNoTests flag on web-client

The web-client’s test script includes --passWithNoTests so that pnpm test succeeds even if test files are temporarily removed during refactoring. This is intentional — remove the flag if you want strict enforcement.