npm package: @forgedevstack/crucible
Description: Testing library for frontend and backend — where code is tested by fire
Author: John Yaghobieh
Latest version: 1.0.0

# 🔥 Crucible

> **Where code is tested by fire** — A zero-dependency testing library for frontend & backend

[![npm](https://img.shields.io/npm/v/@forgedevstack/crucible)](https://www.npmjs.com/package/@forgedevstack/crucible)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![ForgeStack](https://img.shields.io/badge/ForgeStack-crucible-red)](https://forgedevstack.com/crucible)

Part of the [ForgeStack](https://forgedevstack.com) ecosystem.

---

## Features

- 🧪 **Full Test Framework** — `describe`, `it`, `expect`, `beforeAll/afterAll`, `beforeEach/afterEach`
- ⚛️ **React Testing** — Render components, test hooks, fire events, query DOM
- 🌐 **API Testing** — HTTP request helpers, response assertions, mock server
- 🕵️ **Spy / Mock** — Function spies with call tracking, return value mocking
- ⏱️ **Timeouts** — Per-test and global timeouts, `waitFor` async helpers
- 🔗 **Chainable Assertions** — `.toBe()`, `.toEqual()`, `.toContain()`, `.toHaveStatus()`, `.and`
- 📦 **Zero Dependencies** — Pure TypeScript, no external packages
- 🎯 **Tree-shakeable** — Import only `crucible/client` or `crucible/server`

---

## Install

```bash
npm install @forgedevstack/crucible --save-dev
```

---

## Quick Start

### Unit Testing

```typescript
import { describe, it, expect, spy } from '@forgedevstack/crucible';

describe('Math utils', () => {
  it('adds numbers correctly', () => {
    expect(1 + 2).toBe(3);
  });

  it('compares objects deeply', () => {
    expect({ a: 1, b: { c: 2 } }).toEqual({ a: 1, b: { c: 2 } });
  });

  it('tracks spy calls', () => {
    const fn = spy();
    fn('hello');
    fn('world');

    expect(fn).toHaveBeenCalledTimes(2);
    expect(fn).toHaveBeenCalledWith('hello');
  });
});
```

### React Component Testing

```typescript
import { describe, it, expect } from '@forgedevstack/crucible';
import { render, fireEvent } from '@forgedevstack/crucible/client';

describe('Button', () => {
  it('renders text', () => {
    const { getByText } = render(<Button>Click me</Button>);
    expect(getByText('Click me')).toBeDefined();
  });

  it('fires click handler', () => {
    const onClick = spy();
    const { getByRole } = render(<Button onClick={onClick}>Go</Button>);

    fireEvent.click(getByRole('button'));
    expect(onClick).toHaveBeenCalled();
  });
});
```

### React Hook Testing

```typescript
import { describe, it, expect } from '@forgedevstack/crucible';
import { renderHook, act } from '@forgedevstack/crucible/client';
import { useState } from 'react';

describe('useCounter', () => {
  it('increments', async () => {
    const { current } = renderHook(() => {
      const [count, setCount] = useState(0);
      return { count, increment: () => setCount(c => c + 1) };
    });

    expect(current.count).toBe(0);
  });
});
```

### API Testing

```typescript
import { describe, it, expect } from '@forgedevstack/crucible';
import { request, assertResponse, configureRoutes } from '@forgedevstack/crucible/server';

configureRoutes({
  baseUrl: 'http://localhost:4000',
  defaultHeaders: { 'x-api-key': 'test-key' },
});

describe('GET /api/users', () => {
  it('returns users list', async () => {
    const res = await request.get('/api/users');

    assertResponse(res)
      .toHaveStatus(200)
      .toBeJson()
      .and.toRespondWithin(500);
  });

  it('supports auth', async () => {
    const res = await request.get('/api/profile', {
      auth: { bearer: 'my-token' },
    });

    assertResponse(res)
      .toHaveStatus(200)
      .toHaveBodyProperty('email');
  });
});
```

### Mock Server

```typescript
import { describe, it, expect, beforeAll, afterAll } from '@forgedevstack/crucible';
import { createMockServer } from '@forgedevstack/crucible/server';

const server = createMockServer({
  routes: [
    {
      method: 'GET',
      path: '/api/users',
      status: 200,
      body: [{ id: 1, name: 'John' }],
    },
    {
      method: 'POST',
      path: '/api/users',
      handler: (req) => ({
        status: 201,
        body: { id: 2, ...req.body },
      }),
    },
  ],
});

describe('Mock API', () => {
  beforeAll(async () => {
    await server.start();
  });

  afterAll(async () => {
    await server.stop();
  });

  it('intercepts GET requests', async () => {
    const res = await fetch('/api/users');
    const data = await res.json();

    expect(data).toHaveLength(1);
    expect(server.getRequestCount('GET', '/api/users')).toBe(1);
  });

  it('intercepts POST with dynamic handler', async () => {
    const res = await fetch('/api/users', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ name: 'Jane' }),
    });

    expect(res.status).toBe(201);
    const data = await res.json();
    expect(data.name).toBe('Jane');
  });
});
```

---

## API Reference

### Core

| Export | Description |
|--------|-------------|
| `describe(name, fn)` | Define a test suite |
| `describe.skip(name, fn)` | Skip a suite |
| `describe.only(name, fn)` | Run only this suite |
| `it(name, fn, timeout?)` | Define a test case |
| `it.skip(name, fn)` | Skip a test |
| `it.only(name, fn, timeout?)` | Run only this test |
| `test(name, fn)` | Alias for `it` |
| `expect(value)` | Create an assertion |
| `beforeAll(fn)` | Run before all tests in suite |
| `afterAll(fn)` | Run after all tests in suite |
| `beforeEach(fn)` | Run before each test |
| `afterEach(fn)` | Run after each test |
| `run()` | Execute all registered suites |
| `configure(config)` | Set global config |

### Assertions (`expect`)

| Method | Description |
|--------|-------------|
| `.toBe(expected)` | Strict equality (`Object.is`) |
| `.toEqual(expected)` | Deep equality |
| `.toStrictEqual(expected)` | Deep + type equality |
| `.toBeTruthy()` | Value is truthy |
| `.toBeFalsy()` | Value is falsy |
| `.toBeNull()` | Value is null |
| `.toBeUndefined()` | Value is undefined |
| `.toBeDefined()` | Value is not undefined |
| `.toBeNaN()` | Value is NaN |
| `.toBeGreaterThan(n)` | Numeric > |
| `.toBeGreaterThanOrEqual(n)` | Numeric >= |
| `.toBeLessThan(n)` | Numeric < |
| `.toBeLessThanOrEqual(n)` | Numeric <= |
| `.toContain(item)` | Array/string contains |
| `.toHaveLength(n)` | Array/string length |
| `.toMatch(pattern)` | String match |
| `.toThrow(expected?)` | Function throws |
| `.toHaveProperty(key, value?)` | Object has property |
| `.toBeInstanceOf(Class)` | Instance check |
| `.toHaveBeenCalled()` | Spy was called |
| `.toHaveBeenCalledTimes(n)` | Spy call count |
| `.toHaveBeenCalledWith(...args)` | Spy call args |
| `.toMatchObject(partial)` | Partial object match |
| `.toBeCloseTo(n, precision?)` | Floating point |
| `.not` | Negate the assertion |
| `.resolves` | Assert on resolved promise |
| `.rejects` | Assert on rejected promise |

### Spy / Mock

| Export | Description |
|--------|-------------|
| `spy(impl?)` | Create a spy function |
| `spyOn(obj, method)` | Spy on object method |
| `.mockReturnValue(val)` | Set return value |
| `.mockReturnValueOnce(val)` | Set one-time return |
| `.mockImplementation(fn)` | Replace implementation |
| `.mockImplementationOnce(fn)` | Replace once |
| `.mockReset()` | Reset spy completely |
| `.mockClear()` | Clear call history |
| `.mockRestore()` | Restore original |

### Client (`crucible/client`)

| Export | Description |
|--------|-------------|
| `render(ui, options?)` | Render React component |
| `renderHook(hook, options?)` | Render a hook in isolation |
| `cleanup()` | Remove rendered components |
| `fireEvent.click(el)` | Simulate click |
| `fireEvent.change(el, value)` | Simulate input change |
| `fireEvent.keyDown(el, key)` | Simulate key down |
| `fireEvent.submit(el)` | Simulate form submit |
| `fireEvent.focus(el)` | Simulate focus |
| `fireEvent.blur(el)` | Simulate blur |
| `fireEvent.scroll(el)` | Simulate scroll |
| `fireEvent.resize(w, h)` | Simulate window resize |
| `userType(el, text, delay?)` | Type text character by character |
| `userClear(el)` | Clear an input |
| `waitFor(callback, options?)` | Wait for condition |
| `waitForElementToBeRemoved(cb)` | Wait for removal |
| `act(callback)` | Flush React updates |

### Render Result Queries

| Query | Returns | Throws? |
|-------|---------|---------|
| `getByText(text)` | `HTMLElement` | ✅ if not found |
| `getByTestId(id)` | `HTMLElement` | ✅ if not found |
| `getByRole(role)` | `HTMLElement` | ✅ if not found |
| `getByPlaceholder(text)` | `HTMLElement` | ✅ if not found |
| `getByLabel(text)` | `HTMLElement` | ✅ if not found |
| `queryByText(text)` | `HTMLElement \| null` | ❌ |
| `queryByTestId(id)` | `HTMLElement \| null` | ❌ |
| `queryByRole(role)` | `HTMLElement \| null` | ❌ |
| `getAllByText(text)` | `HTMLElement[]` | ✅ if empty |
| `getAllByRole(role)` | `HTMLElement[]` | ✅ if empty |
| `findByText(text)` | `Promise<HTMLElement>` | ✅ on timeout |
| `findByTestId(id)` | `Promise<HTMLElement>` | ✅ on timeout |
| `findByRole(role)` | `Promise<HTMLElement>` | ✅ on timeout |

### Server (`crucible/server`)

| Export | Description |
|--------|-------------|
| `request.get(path, opts?)` | Send GET request |
| `request.post(path, opts?)` | Send POST request |
| `request.put(path, opts?)` | Send PUT request |
| `request.patch(path, opts?)` | Send PATCH request |
| `request.delete(path, opts?)` | Send DELETE request |
| `configureRoutes(config)` | Set route test defaults |
| `assertResponse(res)` | Create response assertion chain |
| `createMockServer(opts?)` | Create a mock server |

### Response Assertions

| Method | Description |
|--------|-------------|
| `.toHaveStatus(code)` | Check status code |
| `.toHaveHeader(key, value?)` | Check header exists/value |
| `.toHaveBody(partial)` | Partial body match |
| `.toHaveBodyProperty(key, val?)` | Check body property |
| `.toHaveCookie(name, value?)` | Check cookie |
| `.toBeJson()` | Content-type is JSON |
| `.toRespondWithin(ms)` | Response time limit |
| `.toHaveContentType(type)` | Check content-type |
| `.and` | Chain multiple assertions |

---

## Subpath Imports

```typescript
// Full library
import { describe, it, expect, render, request } from '@forgedevstack/crucible';

// Client only (React testing)
import { render, renderHook, fireEvent, cleanup } from '@forgedevstack/crucible/client';

// Server only (API testing)
import { request, assertResponse, createMockServer } from '@forgedevstack/crucible/server';
```

---

## Configuration

```typescript
import { configure } from '@forgedevstack/crucible';

configure({
  timeout: 10000,    // Default test timeout (ms)
  verbose: true,     // Print detailed results
  bail: true,        // Stop on first failure
  concurrency: 1,    // Max parallel suites
  colors: true,      // ANSI colors in output
});
```

---

## License

MIT © [John Yaghobieh](https://github.com/yaghobieh)