Skip to content

Commit 929a2b7

Browse files
committed
feat: add vitest and jest plugins for zero-config testing
1 parent a53b0b5 commit 929a2b7

7 files changed

Lines changed: 467 additions & 1 deletion

File tree

docs/docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,8 @@ <h2>The Suite</h2>
305305
</div>
306306
<p class="suite-card-desc">Chaos testing, metrics, drift detection, Docker</p>
307307
<ul class="suite-card-links">
308+
<li><a href="/test-plugins">Vitest &amp; Jest plugins</a></li>
308309
<li><a href="/chaos-testing">Chaos testing</a></li>
309-
<li><a href="/metrics">Prometheus</a></li>
310310
<li><a href="/drift-detection">Drift detection</a></li>
311311
<li><a href="/docker">Docker &amp; Helm</a></li>
312312
</ul>

docs/sidebar.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
{ label: "Record & Replay", href: "/record-replay" },
1010
{ label: "Quick Start: LLM", href: "/chat-completions" },
1111
{ label: "Quick Start: aimock", href: "/aimock-cli" },
12+
{ label: "Examples", href: "/examples" },
1213
],
1314
},
1415
{
@@ -65,6 +66,8 @@
6566
links: [
6667
{ label: "aimock CLI & Config", href: "/aimock-cli" },
6768
{ label: "Docker & Helm", href: "/docker" },
69+
{ label: "GitHub Action", href: "/github-action" },
70+
{ label: "Test Plugins", href: "/test-plugins" },
6871
{ label: "Drift Detection", href: "/drift-detection" },
6972
],
7073
},

docs/test-plugins/index.html

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Test Framework Plugins — aimock</title>
7+
<link rel="icon" type="image/svg+xml" href="../favicon.svg" />
8+
<link rel="preconnect" href="https://fonts.googleapis.com" />
9+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10+
<link
11+
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Instrument+Sans:wght@400;500;600;700&display=swap"
12+
rel="stylesheet"
13+
/>
14+
<link rel="stylesheet" href="../style.css" />
15+
</head>
16+
<body>
17+
<nav class="top-nav">
18+
<div class="nav-inner">
19+
<div style="display: flex; align-items: center; gap: 1rem">
20+
<button
21+
class="sidebar-toggle"
22+
onclick="document.querySelector('.sidebar').classList.toggle('open')"
23+
aria-label="Toggle sidebar"
24+
>
25+
&#9776;
26+
</button>
27+
<a href="/" class="nav-brand"> <span class="prompt">$</span> aimock </a>
28+
</div>
29+
<ul class="nav-links">
30+
<li><a href="/">Home</a></li>
31+
<li><a href="/docs" style="color: var(--accent)">Docs</a></li>
32+
<li>
33+
<a href="https://github.com/CopilotKit/aimock" class="gh-link" target="_blank"
34+
><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
35+
<path
36+
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
37+
/>
38+
</svg>
39+
GitHub</a
40+
>
41+
</li>
42+
</ul>
43+
</div>
44+
</nav>
45+
46+
<div class="docs-layout">
47+
<aside class="sidebar" id="sidebar"></aside>
48+
49+
<main class="docs-content">
50+
<h1>Test Framework Plugins</h1>
51+
<p class="lead">
52+
Zero-config integration for vitest and jest. Import <code>useAimock</code>, write tests
53+
&mdash; the server lifecycle, env vars, and cleanup are handled automatically.
54+
</p>
55+
56+
<h2>Vitest</h2>
57+
<div class="code-block">
58+
<div class="code-block-header">test/app.test.ts <span class="lang-tag">ts</span></div>
59+
<pre><code><span class="kw">import</span> { useAimock } <span class="kw">from</span> <span class="str">"@copilotkit/aimock/vitest"</span>;
60+
61+
<span class="kw">const</span> mock = <span class="fn">useAimock</span>({ <span class="prop">fixtures</span>: <span class="str">"./fixtures"</span> });
62+
63+
<span class="fn">it</span>(<span class="str">"responds to hello"</span>, <span class="kw">async</span> () =&gt; {
64+
<span class="cmt">// OPENAI_BASE_URL is already set</span>
65+
<span class="kw">const</span> res = <span class="kw">await</span> myApp.<span class="fn">chat</span>(<span class="str">"hello"</span>);
66+
<span class="fn">expect</span>(res).<span class="fn">toBe</span>(<span class="str">"Hi there!"</span>);
67+
});</code></pre>
68+
</div>
69+
70+
<h2>Jest</h2>
71+
<div class="code-block">
72+
<div class="code-block-header">test/app.test.ts <span class="lang-tag">ts</span></div>
73+
<pre><code><span class="kw">import</span> { useAimock } <span class="kw">from</span> <span class="str">"@copilotkit/aimock/jest"</span>;
74+
75+
<span class="kw">const</span> mock = <span class="fn">useAimock</span>({ <span class="prop">fixtures</span>: <span class="str">"./fixtures"</span> });
76+
77+
<span class="fn">it</span>(<span class="str">"responds to hello"</span>, <span class="kw">async</span> () =&gt; {
78+
<span class="kw">const</span> res = <span class="kw">await</span> myApp.<span class="fn">chat</span>(<span class="str">"hello"</span>);
79+
<span class="fn">expect</span>(res).<span class="fn">toBe</span>(<span class="str">"Hi there!"</span>);
80+
});</code></pre>
81+
</div>
82+
83+
<h2>Options</h2>
84+
<p>Both plugins accept the same <code>UseAimockOptions</code> object:</p>
85+
<table class="endpoint-table">
86+
<thead>
87+
<tr>
88+
<th>Option</th>
89+
<th>Type</th>
90+
<th>Default</th>
91+
<th>Description</th>
92+
</tr>
93+
</thead>
94+
<tbody>
95+
<tr>
96+
<td><code>fixtures</code></td>
97+
<td><code>string</code></td>
98+
<td>&mdash;</td>
99+
<td>Path to fixture file or directory</td>
100+
</tr>
101+
<tr>
102+
<td><code>patchEnv</code></td>
103+
<td><code>boolean</code></td>
104+
<td><code>true</code></td>
105+
<td>Auto-set <code>OPENAI_BASE_URL</code> and <code>ANTHROPIC_BASE_URL</code></td>
106+
</tr>
107+
<tr>
108+
<td><code>port</code></td>
109+
<td><code>number</code></td>
110+
<td><code>0</code> (random)</td>
111+
<td>Port to listen on</td>
112+
</tr>
113+
<tr>
114+
<td><code>host</code></td>
115+
<td><code>string</code></td>
116+
<td><code>127.0.0.1</code></td>
117+
<td>Host to bind to</td>
118+
</tr>
119+
<tr>
120+
<td><code>logLevel</code></td>
121+
<td><code>string</code></td>
122+
<td><code>silent</code></td>
123+
<td>Log verbosity</td>
124+
</tr>
125+
</tbody>
126+
</table>
127+
128+
<h2>The Handle</h2>
129+
<p>
130+
The getter function returned by <code>useAimock()</code> returns an
131+
<code>AimockHandle</code>:
132+
</p>
133+
<table class="endpoint-table">
134+
<thead>
135+
<tr>
136+
<th>Property</th>
137+
<th>Type</th>
138+
<th>Description</th>
139+
</tr>
140+
</thead>
141+
<tbody>
142+
<tr>
143+
<td><code>llm</code></td>
144+
<td><code>LLMock</code></td>
145+
<td>The LLMock instance &mdash; add fixtures programmatically</td>
146+
</tr>
147+
<tr>
148+
<td><code>url</code></td>
149+
<td><code>string</code></td>
150+
<td>The server URL (e.g., <code>http://127.0.0.1:4010</code>)</td>
151+
</tr>
152+
</tbody>
153+
</table>
154+
155+
<h3>Programmatic fixture registration</h3>
156+
<div class="code-block">
157+
<div class="code-block-header">test/custom.test.ts <span class="lang-tag">ts</span></div>
158+
<pre><code><span class="kw">const</span> mock = <span class="fn">useAimock</span>();
159+
160+
<span class="fn">it</span>(<span class="str">"custom fixture"</span>, <span class="kw">async</span> () =&gt; {
161+
mock().<span class="prop">llm</span>.<span class="fn">onMessage</span>(<span class="str">"custom"</span>, { <span class="prop">content</span>: <span class="str">"Custom response"</span> });
162+
<span class="cmt">// ...</span>
163+
});</code></pre>
164+
</div>
165+
166+
<h2>Lifecycle</h2>
167+
<p>Both plugins register framework hooks to manage the server automatically:</p>
168+
<ol>
169+
<li>
170+
<strong><code>beforeAll</code></strong> &mdash; starts the server, loads fixtures from
171+
the <code>fixtures</code> path, and patches <code>OPENAI_BASE_URL</code> /
172+
<code>ANTHROPIC_BASE_URL</code> environment variables
173+
</li>
174+
<li>
175+
<strong><code>beforeEach</code></strong> &mdash; resets match counts (sequential fixture
176+
counters return to zero, but fixtures themselves are preserved)
177+
</li>
178+
<li>
179+
<strong><code>afterAll</code></strong> &mdash; stops the server and restores the
180+
original environment variables
181+
</li>
182+
</ol>
183+
184+
<h2>Without Plugins (Manual)</h2>
185+
<p>
186+
For comparison, here is the equivalent manual setup. The plugins above handle all of this
187+
for you:
188+
</p>
189+
<div class="code-block">
190+
<div class="code-block-header">test/manual.test.ts <span class="lang-tag">ts</span></div>
191+
<pre><code><span class="kw">import</span> { LLMock } <span class="kw">from</span> <span class="str">"@copilotkit/aimock"</span>;
192+
193+
<span class="kw">let</span> mock: LLMock;
194+
195+
<span class="fn">beforeAll</span>(<span class="kw">async</span> () =&gt; {
196+
mock = <span class="kw">new</span> <span class="fn">LLMock</span>();
197+
mock.<span class="fn">onMessage</span>(<span class="str">"hello"</span>, { <span class="prop">content</span>: <span class="str">"Hi!"</span> });
198+
<span class="kw">await</span> mock.<span class="fn">start</span>();
199+
process.env.OPENAI_BASE_URL = <span class="str">`${mock.url}/v1`</span>;
200+
});
201+
202+
<span class="fn">afterAll</span>(<span class="kw">async</span> () =&gt; {
203+
<span class="kw">await</span> mock.<span class="fn">stop</span>();
204+
<span class="kw">delete</span> process.env.OPENAI_BASE_URL;
205+
});</code></pre>
206+
</div>
207+
</main>
208+
<aside class="page-toc" id="page-toc"></aside>
209+
</div>
210+
<footer class="docs-footer">
211+
<div class="footer-inner">
212+
<div class="footer-left"><span>$</span> aimock &middot; MIT License</div>
213+
<ul class="footer-links">
214+
<li><a href="https://github.com/CopilotKit/aimock" target="_blank">GitHub</a></li>
215+
<li>
216+
<a href="https://www.npmjs.com/package/@copilotkit/aimock" target="_blank">npm</a>
217+
</li>
218+
</ul>
219+
</div>
220+
</footer>
221+
<script src="../sidebar.js"></script>
222+
<script src="../cli-tabs.js"></script>
223+
</body>
224+
</html>

scripts/update-competitive-matrix.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ const FEATURE_RULES: FeatureRule[] = [
140140
rowLabel: "AG-UI event mocking",
141141
keywords: ["ag-ui", "agui", "agent-ui", "copilotkit.*frontend", "event stream mock"],
142142
},
143+
{
144+
rowLabel: "GitHub Action",
145+
keywords: ["github.*action", "action.yml", "uses:.*mock", "ci.*action"],
146+
},
147+
{
148+
rowLabel: "Vitest / Jest plugins",
149+
keywords: [
150+
"vitest.*plugin",
151+
"jest.*plugin",
152+
"useAimock",
153+
"useMock.*test",
154+
"test.*framework.*integrat",
155+
],
156+
},
143157
];
144158

145159
/** Maps competitor display names to their migration page paths (relative to docs/) */

src/jest.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Jest integration for aimock.
3+
*
4+
* Usage:
5+
* import { useAimock } from "@copilotkit/aimock/jest";
6+
*
7+
* const mock = useAimock({ fixtures: "./fixtures" });
8+
*
9+
* it("responds", async () => {
10+
* const res = await fetch(`${mock().url}/v1/chat/completions`, { ... });
11+
* });
12+
*/
13+
14+
/* eslint-disable no-var */
15+
// Jest globals — available at runtime in jest test files
16+
declare var beforeAll: (fn: () => Promise<void> | void, timeout?: number) => void;
17+
declare var afterAll: (fn: () => Promise<void> | void, timeout?: number) => void;
18+
declare var beforeEach: (fn: () => Promise<void> | void, timeout?: number) => void;
19+
/* eslint-enable no-var */
20+
21+
import { LLMock } from "./llmock.js";
22+
import { loadFixtureFile, loadFixturesFromDir } from "./fixture-loader.js";
23+
import type { Fixture, MockServerOptions } from "./types.js";
24+
import { statSync } from "node:fs";
25+
import { resolve } from "node:path";
26+
27+
export interface UseAimockOptions extends MockServerOptions {
28+
/** Path to fixture file or directory. Loaded automatically on start. */
29+
fixtures?: string;
30+
/** If true, sets process.env.OPENAI_BASE_URL to the mock URL + /v1. */
31+
patchEnv?: boolean;
32+
}
33+
34+
export interface AimockHandle {
35+
/** The LLMock instance. */
36+
readonly llm: LLMock;
37+
/** The server URL (e.g., http://127.0.0.1:4010). */
38+
readonly url: string;
39+
}
40+
41+
/**
42+
* Start an aimock server for the duration of the test suite.
43+
*
44+
* - `beforeAll`: starts the server and optionally loads fixtures
45+
* - `beforeEach`: resets fixture match counts (not fixtures themselves)
46+
* - `afterAll`: stops the server
47+
*
48+
* Returns a getter function — call it inside tests to access the handle.
49+
*
50+
* NOTE: Jest globals (beforeAll, afterAll, beforeEach) must be available
51+
* in the test environment. This works with the default jest configuration.
52+
*/
53+
export function useAimock(options: UseAimockOptions = {}): () => AimockHandle {
54+
let handle: AimockHandle | null = null;
55+
56+
beforeAll(async () => {
57+
const { fixtures: fixturePath, patchEnv, ...serverOpts } = options;
58+
const llm = new LLMock(serverOpts);
59+
60+
if (fixturePath) {
61+
const resolved = resolve(fixturePath);
62+
const loadedFixtures = loadFixtures(resolved);
63+
for (const f of loadedFixtures) {
64+
llm.addFixture(f);
65+
}
66+
}
67+
68+
const url = await llm.start();
69+
70+
if (patchEnv !== false) {
71+
process.env.OPENAI_BASE_URL = `${url}/v1`;
72+
process.env.ANTHROPIC_BASE_URL = `${url}/v1`;
73+
}
74+
75+
handle = { llm, url };
76+
});
77+
78+
beforeEach(() => {
79+
if (handle) {
80+
handle.llm.resetMatchCounts();
81+
}
82+
});
83+
84+
afterAll(async () => {
85+
if (handle) {
86+
if (options.patchEnv !== false) {
87+
delete process.env.OPENAI_BASE_URL;
88+
delete process.env.ANTHROPIC_BASE_URL;
89+
}
90+
await handle.llm.stop();
91+
handle = null;
92+
}
93+
});
94+
95+
return () => {
96+
if (!handle) {
97+
throw new Error("useAimock(): server not started — are you calling this inside a test?");
98+
}
99+
return handle;
100+
};
101+
}
102+
103+
function loadFixtures(fixturePath: string): Fixture[] {
104+
try {
105+
const stat = statSync(fixturePath);
106+
if (stat.isDirectory()) {
107+
return loadFixturesFromDir(fixturePath);
108+
}
109+
return loadFixtureFile(fixturePath);
110+
} catch {
111+
return [];
112+
}
113+
}
114+
115+
export { LLMock } from "./llmock.js";
116+
export type { MockServerOptions, Fixture } from "./types.js";

0 commit comments

Comments
 (0)