test: add k6 load testing suite with 5 scenarios

This commit is contained in:
Brad Groux 2026-01-29 05:09:14 -06:00
parent a005020bc5
commit aa0c79e9ea
14 changed files with 1038 additions and 3 deletions

View file

@ -2185,3 +2185,5 @@
{"type":"task.created","taskId":"task_20260129_Av4XVI","status":"todo","id":"evt_m7XO70YXzlog","timestamp":"2026-01-29T11:04:58.821Z"}
{"type":"task.status_changed","taskId":"task_20260129_yAgwCQ","status":"done","previousStatus":"in-progress","id":"evt_KdPRuaIJBbCu","timestamp":"2026-01-29T11:05:08.280Z"}
{"type":"task.status_changed","taskId":"task_20260128_RcHzcp","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_jnx8dyiqxYpi","timestamp":"2026-01-29T11:06:16.056Z"}
{"type":"task.status_changed","taskId":"task_20260128_znWaEu","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_D-Jtdq_-UGvG","timestamp":"2026-01-29T11:07:03.894Z"}
{"type":"task.status_changed","taskId":"task_20260129_5A1uqL","status":"in-progress","previousStatus":"todo","id":"evt_-N1aaujR45Q1","timestamp":"2026-01-29T11:07:03.903Z"}

144
load-tests/README.md Normal file
View file

@ -0,0 +1,144 @@
# Load Tests
Performance and load tests for the Veritas Kanban API using [k6](https://k6.io/).
## Prerequisites
### Install k6
**macOS (Homebrew):**
```bash
brew install k6
```
**Other platforms:**
See [k6 installation docs](https://grafana.com/docs/k6/latest/set-up/install-k6/).
### Verify installation
```bash
k6 version
```
### Start the server
```bash
# From repo root
pnpm dev
```
Server should be running at `http://localhost:3001`.
## Test Scenarios
| Script | VUs | Duration | Description |
| --------------- | ---- | ----------- | --------------------------------------------- |
| `smoke.js` | 1 | 1 iteration | CRUD lifecycle — create, read, update, delete |
| `read-load.js` | 50 | 30s | Read-heavy: list + detail endpoints |
| `write-load.js` | 20 | 30s | Write-heavy: create, update, delete |
| `mixed-load.js` | 0→30 | 60s | 70% reads / 30% writes with ramp-up |
| `ws-stress.js` | 25 | 30s | WebSocket connection stress |
## Running Tests
### Quick smoke test
```bash
pnpm test:load:smoke
# or directly:
k6 run load-tests/k6/smoke.js
```
### Run all load tests
```bash
pnpm test:load
```
### Run a specific scenario
```bash
k6 run load-tests/k6/read-load.js
k6 run load-tests/k6/write-load.js
k6 run load-tests/k6/mixed-load.js
k6 run load-tests/k6/ws-stress.js
```
### Override configuration via environment variables
```bash
# Custom server URL
k6 run -e BASE_URL=http://localhost:4000 load-tests/k6/smoke.js
# Custom API key
k6 run -e API_KEY=my-secret-key load-tests/k6/read-load.js
# Custom WebSocket URL
k6 run -e WS_URL=ws://localhost:4000/ws load-tests/k6/ws-stress.js
```
### Adjust VUs / duration on the fly
```bash
# Override VU count and duration
k6 run --vus 100 --duration 60s load-tests/k6/read-load.js
```
## Interpreting Results
After a run, k6 prints a summary like:
```
✓ list → 200
✓ detail → 200
checks.....................: 100.00% ✓ 4820 ✗ 0
http_req_duration..........: avg=12.3ms min=2.1ms med=10.5ms max=95.2ms p(90)=22.1ms p(95)=31.4ms
http_reqs..................: 4820 160.6/s
errors.....................: 0.00% ✓ 0 ✗ 4820
```
### Key metrics
| Metric | What it means | Target |
| ------------------------- | ------------------------------------ | ----------------------------- |
| `http_req_duration p(95)` | 95th percentile response time | <200ms reads, <500ms writes |
| `errors` | Percentage of failed checks | <1% |
| `http_reqs` | Total requests / requests per second | Higher = better throughput |
| `checks` | Assertion pass rate | 100% for smoke, >99% for load |
| `ws_errors` | WebSocket connection failure rate | <5% |
| `ws_messages_received` | Total WS messages received | >0 per connection |
### Thresholds
Tests define built-in thresholds. If a threshold is breached, k6 exits with code `99`:
- **Read-heavy:** p95 < 200ms, errors < 1%
- **Write-heavy:** p95 < 500ms, errors < 1%
- **Mixed:** p95 < 500ms, errors < 1%
- **WebSocket:** connection errors < 5%
### Export results (optional)
```bash
# JSON output
k6 run --out json=results.json load-tests/k6/read-load.js
# CSV output
k6 run --out csv=results.csv load-tests/k6/read-load.js
```
## File Structure
```
load-tests/
├── README.md # This file
├── config.js # Shared config (base URL, headers, helpers)
└── k6/
├── smoke.js # Scenario 1: CRUD smoke test
├── read-load.js # Scenario 2: Read-heavy load
├── write-load.js # Scenario 3: Write-heavy load
├── mixed-load.js # Scenario 4: Mixed workload
└── ws-stress.js # Scenario 5: WebSocket stress
```

33
load-tests/config.js Normal file
View file

@ -0,0 +1,33 @@
/**
* Shared configuration for k6 load tests.
*
* k6 does NOT use Node.js `export` is ES module syntax
* understood by the k6 runtime.
*/
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001';
export const API_BASE = `${BASE_URL}/api/v1`;
export const WS_URL = __ENV.WS_URL || 'ws://localhost:3001/ws';
export const API_KEY = __ENV.API_KEY || 'test-load-key';
export const defaultHeaders = {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
};
/**
* Generate a unique task payload for creation.
* @param {string} prefix - Prefix for the task title
* @returns {object} Task creation payload
*/
export function makeTask(prefix = 'load-test') {
const ts = Date.now();
const rand = Math.random().toString(36).substring(2, 8);
return {
title: `${prefix}-${ts}-${rand}`,
description: `Load test task created at ${new Date().toISOString()}`,
type: 'code',
priority: 'low',
};
}

113
load-tests/k6/mixed-load.js Normal file
View file

@ -0,0 +1,113 @@
/**
* Scenario 4: Mixed Workload
*
* 70 % reads, 30 % writes 30 virtual users.
* Ramp up from 0 30 over 10 s, hold for 50 s.
* Total duration: 60 seconds.
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
import { API_BASE, defaultHeaders, makeTask } from '../config.js';
const errorRate = new Rate('errors');
export const options = {
stages: [
{ duration: '10s', target: 30 }, // ramp up
{ duration: '50s', target: 30 }, // hold
],
thresholds: {
http_req_duration: ['p(95)<500'],
errors: ['rate<0.01'],
},
};
// ── Read scenario (70 %) ─────────────────────────────────────
function readScenario() {
const listRes = http.get(`${API_BASE}/tasks`, {
headers: defaultHeaders,
tags: { name: 'GET /tasks' },
});
const listOk = check(listRes, { 'list → 200': (r) => r.status === 200 });
errorRate.add(!listOk);
// Read a random task from the list
try {
const body = JSON.parse(listRes.body);
const tasks = Array.isArray(body) ? body : body.tasks || [];
if (tasks.length > 0) {
const id = tasks[Math.floor(Math.random() * tasks.length)].id;
const detailRes = http.get(`${API_BASE}/tasks/${id}`, {
headers: defaultHeaders,
tags: { name: 'GET /tasks/:id' },
});
const detailOk = check(detailRes, { 'detail → 200': (r) => r.status === 200 });
errorRate.add(!detailOk);
}
} catch {
// ignore
}
sleep(0.3);
}
// ── Write scenario (30 %) ────────────────────────────────────
function writeScenario() {
const payload = makeTask('mixed');
const createRes = http.post(
`${API_BASE}/tasks`,
JSON.stringify(payload),
{
headers: defaultHeaders,
tags: { name: 'POST /tasks' },
}
);
const createOk = check(createRes, { 'create → 201': (r) => r.status === 201 });
errorRate.add(!createOk);
if (!createOk) {
sleep(0.5);
return;
}
const created = JSON.parse(createRes.body);
const taskId = created.id || created.task?.id;
sleep(0.2);
// Update
const updateRes = http.patch(
`${API_BASE}/tasks/${taskId}`,
JSON.stringify({ priority: 'high' }),
{
headers: defaultHeaders,
tags: { name: 'PATCH /tasks/:id' },
}
);
const updateOk = check(updateRes, { 'update → 200': (r) => r.status === 200 });
errorRate.add(!updateOk);
sleep(0.2);
// Delete (cleanup)
const delRes = http.del(`${API_BASE}/tasks/${taskId}`, null, {
headers: defaultHeaders,
tags: { name: 'DELETE /tasks/:id' },
});
const delOk = check(delRes, { 'delete → 200/204': (r) => r.status === 200 || r.status === 204 });
errorRate.add(!delOk);
sleep(0.3);
}
// ── Main ─────────────────────────────────────────────────────
export default function () {
if (Math.random() < 0.7) {
readScenario();
} else {
writeScenario();
}
}

View file

@ -0,0 +1,74 @@
/**
* Scenario 2: Read-Heavy Load
*
* 50 virtual users hammering GET /tasks and GET /tasks/:id
* for 30 seconds.
*
* Thresholds:
* p(95) response time < 200 ms
* error rate < 1 %
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { API_BASE, defaultHeaders } from '../config.js';
const errorRate = new Rate('errors');
const listDuration = new Trend('list_duration', true);
const detailDuration = new Trend('detail_duration', true);
export const options = {
vus: 50,
duration: '30s',
thresholds: {
http_req_duration: ['p(95)<200'],
errors: ['rate<0.01'],
},
};
export default function () {
// ── List tasks ─────────────────────────────────────────────
const listRes = http.get(`${API_BASE}/tasks`, {
headers: defaultHeaders,
tags: { name: 'GET /tasks' },
});
listDuration.add(listRes.timings.duration);
const listOk = check(listRes, {
'list → 200': (r) => r.status === 200,
});
errorRate.add(!listOk);
// Grab a task id from the list for detail reads
let taskId = null;
try {
const body = JSON.parse(listRes.body);
const tasks = Array.isArray(body) ? body : body.tasks || [];
if (tasks.length > 0) {
// Pick a random task
taskId = tasks[Math.floor(Math.random() * tasks.length)].id;
}
} catch {
// ignore parse errors
}
sleep(0.1);
// ── Read single task (if we found one) ─────────────────────
if (taskId) {
const detailRes = http.get(`${API_BASE}/tasks/${taskId}`, {
headers: defaultHeaders,
tags: { name: 'GET /tasks/:id' },
});
detailDuration.add(detailRes.timings.duration);
const detailOk = check(detailRes, {
'detail → 200': (r) => r.status === 200,
});
errorRate.add(!detailOk);
}
sleep(0.2);
}

104
load-tests/k6/smoke.js Normal file
View file

@ -0,0 +1,104 @@
/**
* Scenario 1: API CRUD Smoke Test
*
* Exercises the full task lifecycle:
* Create Read Update Delete
*
* 1 virtual user, 1 iteration.
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { API_BASE, defaultHeaders, makeTask } from '../config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: {
checks: ['rate==1.0'], // every check must pass
},
};
export default function () {
// ── CREATE ─────────────────────────────────────────────────
const payload = makeTask('smoke');
const createRes = http.post(
`${API_BASE}/tasks`,
JSON.stringify(payload),
{ headers: defaultHeaders }
);
const createOk = check(createRes, {
'POST /tasks → 201': (r) => r.status === 201,
'POST /tasks → has id': (r) => {
try {
const body = JSON.parse(r.body);
return !!(body.id || (body.task && body.task.id));
} catch {
return false;
}
},
});
if (!createOk) {
console.error(`CREATE failed: ${createRes.status}${createRes.body}`);
return;
}
const created = JSON.parse(createRes.body);
const taskId = created.id || created.task?.id;
sleep(0.3);
// ── READ (single) ─────────────────────────────────────────
const getRes = http.get(`${API_BASE}/tasks/${taskId}`, {
headers: defaultHeaders,
});
check(getRes, {
'GET /tasks/:id → 200': (r) => r.status === 200,
});
sleep(0.3);
// ── UPDATE ─────────────────────────────────────────────────
const updateRes = http.patch(
`${API_BASE}/tasks/${taskId}`,
JSON.stringify({ title: `${payload.title}-updated`, priority: 'high' }),
{ headers: defaultHeaders }
);
check(updateRes, {
'PATCH /tasks/:id → 200': (r) => r.status === 200,
});
sleep(0.3);
// ── READ LIST ──────────────────────────────────────────────
const listRes = http.get(`${API_BASE}/tasks`, {
headers: defaultHeaders,
});
check(listRes, {
'GET /tasks → 200': (r) => r.status === 200,
'GET /tasks → is array or has tasks': (r) => {
try {
const body = JSON.parse(r.body);
return Array.isArray(body) || Array.isArray(body.tasks);
} catch {
return false;
}
},
});
sleep(0.3);
// ── DELETE ─────────────────────────────────────────────────
const delRes = http.del(`${API_BASE}/tasks/${taskId}`, null, {
headers: defaultHeaders,
});
check(delRes, {
'DELETE /tasks/:id → 200 or 204': (r) =>
r.status === 200 || r.status === 204,
});
}

View file

@ -0,0 +1,87 @@
/**
* Scenario 3: Write-Heavy Load
*
* 20 virtual users creating, updating, and deleting tasks
* for 30 seconds.
*
* Thresholds:
* p(95) response time < 500 ms
* error rate < 1 %
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
import { API_BASE, defaultHeaders, makeTask } from '../config.js';
const errorRate = new Rate('errors');
export const options = {
vus: 20,
duration: '30s',
thresholds: {
http_req_duration: ['p(95)<500'],
errors: ['rate<0.01'],
},
};
export default function () {
// ── CREATE ─────────────────────────────────────────────────
const payload = makeTask('write-load');
const createRes = http.post(
`${API_BASE}/tasks`,
JSON.stringify(payload),
{
headers: defaultHeaders,
tags: { name: 'POST /tasks' },
}
);
const createOk = check(createRes, {
'create → 201': (r) => r.status === 201,
});
errorRate.add(!createOk);
if (!createOk) {
sleep(0.5);
return;
}
const created = JSON.parse(createRes.body);
const taskId = created.id || created.task?.id;
sleep(0.2);
// ── UPDATE ─────────────────────────────────────────────────
const updateRes = http.patch(
`${API_BASE}/tasks/${taskId}`,
JSON.stringify({
title: `${payload.title}-updated`,
priority: 'high',
description: 'Updated during write-load test',
}),
{
headers: defaultHeaders,
tags: { name: 'PATCH /tasks/:id' },
}
);
const updateOk = check(updateRes, {
'update → 200': (r) => r.status === 200,
});
errorRate.add(!updateOk);
sleep(0.2);
// ── DELETE ─────────────────────────────────────────────────
const delRes = http.del(`${API_BASE}/tasks/${taskId}`, null, {
headers: defaultHeaders,
tags: { name: 'DELETE /tasks/:id' },
});
const delOk = check(delRes, {
'delete → 200 or 204': (r) => r.status === 200 || r.status === 204,
});
errorRate.add(!delOk);
sleep(0.3);
}

View file

@ -0,0 +1,66 @@
/**
* Scenario 5: WebSocket Stress
*
* 25 concurrent WebSocket connections.
* Each connection stays open for 30 seconds, verifying
* that the connection is established and messages can be received.
*/
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Rate, Counter } from 'k6/metrics';
import { WS_URL, API_KEY } from '../config.js';
const wsErrors = new Rate('ws_errors');
const wsMessages = new Counter('ws_messages_received');
export const options = {
vus: 25,
duration: '30s',
thresholds: {
ws_errors: ['rate<0.05'], // <5% connection errors
},
};
export default function () {
const url = `${WS_URL}?apiKey=${API_KEY}`;
const res = ws.connect(url, {}, function (socket) {
socket.on('open', () => {
// Send a ping / subscribe message to trigger responses
socket.send(JSON.stringify({ type: 'ping' }));
});
socket.on('message', (data) => {
wsMessages.add(1);
// Validate message is parseable JSON
try {
JSON.parse(data);
} catch {
// Binary or non-JSON frames are acceptable
}
});
socket.on('error', (e) => {
wsErrors.add(1);
console.error(`WS error: ${e}`);
});
// Keep connection alive — send periodic pings
socket.setInterval(() => {
socket.send(JSON.stringify({ type: 'ping' }));
}, 5000);
// Hold the connection open for the test duration
socket.setTimeout(() => {
socket.close();
}, 28000); // close slightly before 30s to avoid abrupt teardown
});
const connected = check(res, {
'WS connected (101)': (r) => r && r.status === 101,
});
wsErrors.add(!connected);
sleep(1);
}

View file

@ -21,6 +21,8 @@
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"test:load:smoke": "k6 run load-tests/k6/smoke.js",
"test:load": "k6 run load-tests/k6/smoke.js && k6 run load-tests/k6/read-load.js && k6 run load-tests/k6/write-load.js && k6 run load-tests/k6/mixed-load.js && k6 run load-tests/k6/ws-stress.js",
"clean": "pnpm -r clean && rm -rf node_modules",
"audit": "pnpm audit --prod",
"audit:all": "pnpm audit",

View file

@ -1,4 +1,26 @@
[
{
"id": "activity_1769684823903_4j3z6nj0v",
"type": "status_changed",
"taskId": "task_20260129_5A1uqL",
"taskTitle": "[v1.1] TESTING: Create load testing suite with k6 or Artillery",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-29T11:07:03.903Z"
},
{
"id": "activity_1769684823894_d7xlvr08f",
"type": "status_changed",
"taskId": "task_20260128_znWaEu",
"taskTitle": "QUALITY: Add frontend unit tests (1 test for 152 source files)",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-29T11:07:03.894Z"
},
{
"id": "activity_1769684783257_lolvoukac",
"type": "comment_added",

View file

@ -1,5 +1,5 @@
{
"status": "idle",
"subAgentCount": 0,
"lastUpdated": "2026-01-29T11:04:59.927Z"
"status": "sub-agent",
"subAgentCount": 2,
"lastUpdated": "2026-01-29T11:07:03.912Z"
}

View file

@ -1,4 +1,12 @@
[
{
"id": "status_1769684823912_uwsoqieu5",
"timestamp": "2026-01-29T11:07:03.912Z",
"previousStatus": "idle",
"newStatus": "sub-agent",
"subAgentCount": 2,
"durationMs": 319196
},
{
"id": "status_1769684504716_zgmnogkbo",
"timestamp": "2026-01-29T11:01:44.716Z",

View file

@ -0,0 +1,102 @@
/**
* Tests for lib/api/helpers.ts handleResponse envelope unwrapping.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleResponse } from '@/lib/api/helpers';
// ── Helpers ──────────────────────────────────────────────────
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}
function brokenJsonResponse(status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => {
throw new SyntaxError('Unexpected token');
},
} as Response;
}
// ── Tests ────────────────────────────────────────────────────
describe('handleResponse', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns undefined for 204 No Content', async () => {
const response = { ok: true, status: 204, json: vi.fn() } as unknown as Response;
const result = await handleResponse<void>(response);
expect(result).toBeUndefined();
// json() should NOT be called for 204
expect(response.json).not.toHaveBeenCalled();
});
it('unwraps a success envelope and returns data', async () => {
const body = {
success: true,
data: { id: '1', title: 'Hello' },
meta: { timestamp: '2025-01-01T00:00:00Z' },
};
const result = await handleResponse<{ id: string; title: string }>(jsonResponse(body));
expect(result).toEqual({ id: '1', title: 'Hello' });
});
it('throws with server message for error envelope', async () => {
const body = {
success: false,
error: { code: 'NOT_FOUND', message: 'Task not found' },
meta: { timestamp: '2025-01-01T00:00:00Z' },
};
await expect(handleResponse(jsonResponse(body, 404))).rejects.toThrow('Task not found');
});
it('includes code and details on error envelope errors', async () => {
const body = {
success: false,
error: { code: 'VALIDATION', message: 'Bad input', details: { field: 'title' } },
meta: { timestamp: '2025-01-01T00:00:00Z' },
};
try {
await handleResponse(jsonResponse(body, 400));
expect.fail('Should have thrown');
} catch (err: unknown) {
const e = err as Error & { code?: string; details?: unknown };
expect(e.code).toBe('VALIDATION');
expect(e.details).toEqual({ field: 'title' });
}
});
it('falls through to non-envelope path for non-ok response', async () => {
const body = { error: 'Internal Server Error' };
await expect(handleResponse(jsonResponse(body, 500))).rejects.toThrow('Internal Server Error');
});
it('uses HTTP status for non-ok non-envelope response without error string', async () => {
const body = { some: 'data' };
await expect(handleResponse(jsonResponse(body, 502))).rejects.toThrow('HTTP 502');
});
it('returns raw body for non-envelope OK response', async () => {
const body = [1, 2, 3];
const result = await handleResponse<number[]>(jsonResponse(body));
expect(result).toEqual([1, 2, 3]);
});
it('handles null body from broken json gracefully for non-ok', async () => {
// When json() throws, body is null; should throw "HTTP {status}"
await expect(handleResponse(brokenJsonResponse(500))).rejects.toThrow('HTTP 500');
});
it('returns null body as-is for ok response with broken json', async () => {
const result = await handleResponse(brokenJsonResponse(200));
expect(result).toBeNull();
});
});

View file

@ -0,0 +1,278 @@
/**
* Shared test utilities custom render wrapper, mock factories, and helpers.
*/
import React, { type ReactNode } from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WebSocketStatusProvider } from '@/contexts/WebSocketContext';
import type { ConnectionState } from '@/hooks/useWebSocket';
import type {
Task,
TaskStatus,
TaskPriority,
TaskTypeConfig,
ProjectConfig,
SprintConfig,
} from '@veritas-kanban/shared';
// ── Query Client Factory ─────────────────────────────────────
/** Create a fresh QueryClient configured for testing (no retries, no GC). */
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
});
}
// ── WebSocket Status Defaults ────────────────────────────────
export interface TestWebSocketStatus {
isConnected: boolean;
connectionState: ConnectionState;
reconnectAttempt: number;
}
const DEFAULT_WS_STATUS: TestWebSocketStatus = {
isConnected: true,
connectionState: 'connected',
reconnectAttempt: 0,
};
// ── All Providers Wrapper ────────────────────────────────────
interface AllProvidersProps {
children: ReactNode;
queryClient?: QueryClient;
wsStatus?: Partial<TestWebSocketStatus>;
}
function AllProviders({ children, queryClient, wsStatus }: AllProvidersProps) {
const qc = queryClient ?? createTestQueryClient();
const ws = { ...DEFAULT_WS_STATUS, ...wsStatus };
return (
<QueryClientProvider client={qc}>
<WebSocketStatusProvider
isConnected={ws.isConnected}
connectionState={ws.connectionState}
reconnectAttempt={ws.reconnectAttempt}
>
{children}
</WebSocketStatusProvider>
</QueryClientProvider>
);
}
// ── Custom Render ────────────────────────────────────────────
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
wsStatus?: Partial<TestWebSocketStatus>;
}
/**
* Render with all necessary providers (QueryClient, WebSocket context).
* Accepts optional overrides for each provider.
*/
export function renderWithProviders(ui: React.ReactElement, options: CustomRenderOptions = {}) {
const { queryClient, wsStatus, ...renderOptions } = options;
const qc = queryClient ?? createTestQueryClient();
return {
...render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<AllProviders queryClient={qc} wsStatus={wsStatus}>
{children}
</AllProviders>
),
...renderOptions,
}),
queryClient: qc,
};
}
// ── Mock Data Factories ──────────────────────────────────────
let taskCounter = 0;
/** Create a mock Task with sensible defaults. Override any field. */
export function createMockTask(overrides: Partial<Task> = {}): Task {
taskCounter += 1;
const id = overrides.id ?? `task_${taskCounter}`;
return {
id,
title: `Test Task ${taskCounter}`,
description: `Description for task ${taskCounter}`,
type: 'feature',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z',
...overrides,
};
}
/** Create a batch of mock tasks with sequential defaults. */
export function createMockTasks(count: number, overrides: Partial<Task> = {}): Task[] {
return Array.from({ length: count }, () => createMockTask(overrides));
}
/** Create a mock TaskTypeConfig. */
export function createMockTaskType(overrides: Partial<TaskTypeConfig> = {}): TaskTypeConfig {
return {
id: 'feature',
label: 'Feature',
icon: 'Code',
order: 0,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z',
...overrides,
};
}
/** Create a mock ProjectConfig. */
export function createMockProject(overrides: Partial<ProjectConfig> = {}): ProjectConfig {
return {
id: 'proj-1',
label: 'Test Project',
order: 0,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z',
...overrides,
};
}
/** Create a mock SprintConfig. */
export function createMockSprint(overrides: Partial<SprintConfig> = {}): SprintConfig {
return {
id: 'sprint-1',
label: 'Sprint 1',
order: 0,
created: '2025-01-01T00:00:00Z',
updated: '2025-01-01T00:00:00Z',
...overrides,
};
}
// ── Fetch Mock Helper ────────────────────────────────────────
/**
* Mock global fetch with an envelope response.
* Returns the mock so you can inspect calls and change return values.
*/
export function mockFetch(data: unknown, ok = true) {
const response = {
ok,
status: ok ? 200 : 500,
json: async () => ({
success: ok,
data,
meta: { timestamp: new Date().toISOString() },
...(ok ? {} : { error: { code: 'TEST_ERROR', message: 'Test error' } }),
}),
} as Response;
const fetchMock = globalThis.fetch as ReturnType<typeof import('vitest').vi.fn> | undefined;
if (fetchMock && typeof fetchMock.mockResolvedValue === 'function') {
fetchMock.mockResolvedValue(response);
return fetchMock;
}
const mock = Object.assign(async () => response, {
mockResolvedValue: () => {},
}) as unknown as typeof globalThis.fetch;
globalThis.fetch = mock;
return mock;
}
/**
* Create a mock WebSocket class for testing.
* Does NOT actually open a connection; instead it exposes helpers
* so tests can manually trigger onopen / onmessage / onclose / onerror.
*/
export function createMockWebSocket() {
const instances: MockWebSocketInstance[] = [];
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSING = 2;
readonly CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
url: string;
protocol = '';
extensions = '';
bufferedAmount = 0;
binaryType: BinaryType = 'blob';
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
sent: string[] = [];
constructor(url: string | URL, _protocols?: string | string[]) {
this.url = typeof url === 'string' ? url : url.toString();
instances.push(this as unknown as MockWebSocketInstance);
}
send(data: string) {
this.sent.push(data);
}
close(_code?: number, _reason?: string) {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close', { code: _code, reason: _reason }));
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {
return false;
}
// Test helpers
simulateOpen() {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event('open'));
}
simulateMessage(data: Record<string, unknown>) {
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
}
simulateError() {
this.onerror?.(new Event('error'));
}
simulateClose(code = 1000, reason = '') {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close', { code, reason }));
}
}
type MockWebSocketInstance = InstanceType<typeof MockWebSocket>;
return {
MockWebSocket: MockWebSocket as unknown as typeof WebSocket,
instances,
/** Get the most recently created instance. */
get latest() {
return instances[instances.length - 1];
},
};
}