aimock-pytest 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ .worktrees/
5
+ .superpowers/
6
+ coverage/
7
+ **/__pycache__/
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: aimock-pytest
3
+ Version: 0.1.0
4
+ Summary: pytest fixtures for aimock — mock LLM APIs, MCP, A2A, vector DBs
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: requests>=2.28
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=7.0; extra == 'test'
10
+ Requires-Dist: requests>=2.28; extra == 'test'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # aimock-pytest
14
+
15
+ pytest fixtures for [aimock](https://github.com/CopilotKit/llmock) — mock LLM APIs, MCP tools, A2A agents, vector databases, and more.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ # From PyPI (once published):
21
+ pip install aimock-pytest
22
+
23
+ # Local install from a repo checkout:
24
+ pip install ./packages/aimock-pytest
25
+ ```
26
+
27
+ **Requires:** Node.js >= 20 on `PATH` (or set `AIMOCK_NODE_PATH`).
28
+
29
+ ## Quick Start
30
+
31
+ The plugin auto-registers two fixtures: `aimock` (function-scoped) and `aimock_session` (session-scoped).
32
+
33
+ ```python
34
+ def test_hello(aimock):
35
+ import requests
36
+
37
+ # Set up a fixture
38
+ aimock.on_message("hello", {"content": "Hi there!"})
39
+
40
+ # Point your SDK at aimock
41
+ r = requests.post(
42
+ f"{aimock.base_url}/v1/chat/completions",
43
+ json={
44
+ "model": "gpt-4",
45
+ "messages": [{"role": "user", "content": "hello"}],
46
+ },
47
+ )
48
+ assert r.json()["choices"][0]["message"]["content"] == "Hi there!"
49
+ ```
50
+
51
+ ## Fixtures
52
+
53
+ | Fixture | Scope | Description |
54
+ | ---------------- | -------- | ------------------------------ |
55
+ | `aimock` | function | Fresh server per test |
56
+ | `aimock_session` | session | Shared server across all tests |
57
+
58
+ ## Server API
59
+
60
+ ```python
61
+ # Add fixtures
62
+ aimock.on_message("pattern", {"content": "response"})
63
+ aimock.on_embedding("pattern", {"embedding": [0.1, 0.2]})
64
+ aimock.add_fixture(match={...}, response={...}, chunkSize=10, latency=50)
65
+ aimock.load_fixtures("path/to/fixtures.json")
66
+
67
+ # Inspect
68
+ aimock.get_journal() # list of all recorded requests
69
+ aimock.get_last_request() # most recent request or None
70
+
71
+ # Error injection
72
+ aimock.next_error(429, {"message": "Rate limited"})
73
+
74
+ # Reset
75
+ aimock.clear_fixtures() # remove all fixtures
76
+ aimock.reset() # clear fixtures + journal
77
+ ```
78
+
79
+ ## CLI Options
80
+
81
+ ```
82
+ --aimock-node PATH Path to node binary
83
+ --aimock-version VER aimock npm version (default: 1.7.0)
84
+ ```
85
+
86
+ ## Environment Variables
87
+
88
+ | Variable | Description |
89
+ | ------------------ | ----------------------------------------------------- |
90
+ | `AIMOCK_NODE_PATH` | Path to node binary |
91
+ | `AIMOCK_CACHE_DIR` | Override cache directory (default: `~/.cache/aimock`) |
92
+
93
+ ## Development
94
+
95
+ ### Prerequisites
96
+
97
+ - Node.js >= 20
98
+ - Python >= 3.10
99
+ - pnpm
100
+
101
+ ### Running tests locally
102
+
103
+ Build the npm package first, then point `AIMOCK_CLI_PATH` at the local build:
104
+
105
+ ```bash
106
+ pnpm install && pnpm run build
107
+ AIMOCK_CLI_PATH=../../dist/cli.js pytest tests/ -v
108
+ ```
109
+
110
+ If you install the test dependencies and run from the `packages/aimock-pytest/`
111
+ directory, `conftest.py` will auto-detect the local build so you can omit the
112
+ env var:
113
+
114
+ ```bash
115
+ pip install ./packages/aimock-pytest[test]
116
+ cd packages/aimock-pytest
117
+ pytest tests/ -v
118
+ ```
119
+
120
+ ### How CI works
121
+
122
+ The `test-pytest.yml` workflow:
123
+
124
+ 1. Checks out the repo
125
+ 2. Builds the TypeScript package (`pnpm run build`)
126
+ 3. Sets `AIMOCK_CLI_PATH` to the local `dist/cli.js`
127
+ 4. Installs `aimock-pytest[test]` and runs `pytest`
128
+
129
+ Tests run across a matrix of Python 3.10--3.13 and Node 20/22.
130
+
131
+ The `publish-pytest.yml` workflow publishes to PyPI on pushes to `main` when
132
+ the version in `pyproject.toml` has not already been published.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,124 @@
1
+ # aimock-pytest
2
+
3
+ pytest fixtures for [aimock](https://github.com/CopilotKit/llmock) — mock LLM APIs, MCP tools, A2A agents, vector databases, and more.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # From PyPI (once published):
9
+ pip install aimock-pytest
10
+
11
+ # Local install from a repo checkout:
12
+ pip install ./packages/aimock-pytest
13
+ ```
14
+
15
+ **Requires:** Node.js >= 20 on `PATH` (or set `AIMOCK_NODE_PATH`).
16
+
17
+ ## Quick Start
18
+
19
+ The plugin auto-registers two fixtures: `aimock` (function-scoped) and `aimock_session` (session-scoped).
20
+
21
+ ```python
22
+ def test_hello(aimock):
23
+ import requests
24
+
25
+ # Set up a fixture
26
+ aimock.on_message("hello", {"content": "Hi there!"})
27
+
28
+ # Point your SDK at aimock
29
+ r = requests.post(
30
+ f"{aimock.base_url}/v1/chat/completions",
31
+ json={
32
+ "model": "gpt-4",
33
+ "messages": [{"role": "user", "content": "hello"}],
34
+ },
35
+ )
36
+ assert r.json()["choices"][0]["message"]["content"] == "Hi there!"
37
+ ```
38
+
39
+ ## Fixtures
40
+
41
+ | Fixture | Scope | Description |
42
+ | ---------------- | -------- | ------------------------------ |
43
+ | `aimock` | function | Fresh server per test |
44
+ | `aimock_session` | session | Shared server across all tests |
45
+
46
+ ## Server API
47
+
48
+ ```python
49
+ # Add fixtures
50
+ aimock.on_message("pattern", {"content": "response"})
51
+ aimock.on_embedding("pattern", {"embedding": [0.1, 0.2]})
52
+ aimock.add_fixture(match={...}, response={...}, chunkSize=10, latency=50)
53
+ aimock.load_fixtures("path/to/fixtures.json")
54
+
55
+ # Inspect
56
+ aimock.get_journal() # list of all recorded requests
57
+ aimock.get_last_request() # most recent request or None
58
+
59
+ # Error injection
60
+ aimock.next_error(429, {"message": "Rate limited"})
61
+
62
+ # Reset
63
+ aimock.clear_fixtures() # remove all fixtures
64
+ aimock.reset() # clear fixtures + journal
65
+ ```
66
+
67
+ ## CLI Options
68
+
69
+ ```
70
+ --aimock-node PATH Path to node binary
71
+ --aimock-version VER aimock npm version (default: 1.7.0)
72
+ ```
73
+
74
+ ## Environment Variables
75
+
76
+ | Variable | Description |
77
+ | ------------------ | ----------------------------------------------------- |
78
+ | `AIMOCK_NODE_PATH` | Path to node binary |
79
+ | `AIMOCK_CACHE_DIR` | Override cache directory (default: `~/.cache/aimock`) |
80
+
81
+ ## Development
82
+
83
+ ### Prerequisites
84
+
85
+ - Node.js >= 20
86
+ - Python >= 3.10
87
+ - pnpm
88
+
89
+ ### Running tests locally
90
+
91
+ Build the npm package first, then point `AIMOCK_CLI_PATH` at the local build:
92
+
93
+ ```bash
94
+ pnpm install && pnpm run build
95
+ AIMOCK_CLI_PATH=../../dist/cli.js pytest tests/ -v
96
+ ```
97
+
98
+ If you install the test dependencies and run from the `packages/aimock-pytest/`
99
+ directory, `conftest.py` will auto-detect the local build so you can omit the
100
+ env var:
101
+
102
+ ```bash
103
+ pip install ./packages/aimock-pytest[test]
104
+ cd packages/aimock-pytest
105
+ pytest tests/ -v
106
+ ```
107
+
108
+ ### How CI works
109
+
110
+ The `test-pytest.yml` workflow:
111
+
112
+ 1. Checks out the repo
113
+ 2. Builds the TypeScript package (`pnpm run build`)
114
+ 3. Sets `AIMOCK_CLI_PATH` to the local `dist/cli.js`
115
+ 4. Installs `aimock-pytest[test]` and runs `pytest`
116
+
117
+ Tests run across a matrix of Python 3.10--3.13 and Node 20/22.
118
+
119
+ The `publish-pytest.yml` workflow publishes to PyPI on pushes to `main` when
120
+ the version in `pyproject.toml` has not already been published.
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aimock-pytest"
7
+ version = "0.1.0"
8
+ description = "pytest fixtures for aimock — mock LLM APIs, MCP, A2A, vector DBs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["requests>=2.28"]
13
+
14
+ [project.optional-dependencies]
15
+ test = ["pytest>=7.0", "requests>=2.28"]
16
+
17
+ [project.entry-points."pytest11"]
18
+ aimock = "aimock_pytest.plugin"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/aimock_pytest"]
@@ -0,0 +1,5 @@
1
+ from aimock_pytest._version import AIMOCK_VERSION
2
+ from aimock_pytest._server import AIMockServer
3
+ from aimock_pytest._node_manager import NodeManager
4
+
5
+ __all__ = ["AIMockServer", "NodeManager", "AIMOCK_VERSION"]
@@ -0,0 +1,189 @@
1
+ """Handles Node.js detection and npm tarball caching for aimock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ import fcntl
7
+ except ImportError:
8
+ fcntl = None # Windows — file locking not available
9
+
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tarfile
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ import requests
20
+
21
+ from aimock_pytest._version import AIMOCK_VERSION
22
+
23
+
24
+ class NodeManager:
25
+ """Manages node binary detection and aimock npm package caching."""
26
+
27
+ # Use @copilotkit/aimock since that's the published npm package name.
28
+ _NPM_TARBALL_URL = (
29
+ "https://registry.npmjs.org/@copilotkit/aimock/-/aimock-{version}.tgz"
30
+ )
31
+ _MIN_NODE_MAJOR = 20
32
+
33
+ def __init__(
34
+ self,
35
+ version: str = AIMOCK_VERSION,
36
+ cache_dir: str | Path | None = None,
37
+ node_path: str | None = None,
38
+ ) -> None:
39
+ self.version = version
40
+ self._custom_cache_dir = Path(cache_dir) if cache_dir else None
41
+ self._custom_node_path = node_path
42
+
43
+ # ── public API ──────────────────────────────────────────────────────
44
+
45
+ def ensure_installed(self) -> Path:
46
+ """Return path to the aimock ``dist/cli.js``. Downloads if needed.
47
+
48
+ If ``AIMOCK_CLI_PATH`` is set, returns that path directly (bypassing
49
+ the npm tarball download entirely). This is used for local development
50
+ and CI where we test against the repo's own build.
51
+ """
52
+ env_cli = os.environ.get("AIMOCK_CLI_PATH")
53
+ if env_cli:
54
+ p = Path(env_cli)
55
+ if not p.is_file():
56
+ raise RuntimeError(
57
+ f"AIMOCK_CLI_PATH is set to {env_cli!r} but the file does not exist"
58
+ )
59
+ return p
60
+
61
+ cli_js = self._package_dir() / "dist" / "cli.js"
62
+ if cli_js.is_file():
63
+ return cli_js
64
+
65
+ self._download_tarball()
66
+
67
+ if not cli_js.is_file():
68
+ raise RuntimeError(
69
+ f"Expected cli.js at {cli_js} but it was not found after extraction"
70
+ )
71
+ return cli_js
72
+
73
+ def find_node(self) -> str:
74
+ """Find the node binary. Checks ``AIMOCK_NODE_PATH`` env, the
75
+ constructor override, then ``PATH``."""
76
+ # Explicit override from constructor
77
+ if self._custom_node_path:
78
+ self._verify_node(self._custom_node_path)
79
+ return self._custom_node_path
80
+
81
+ # Environment variable
82
+ env_path = os.environ.get("AIMOCK_NODE_PATH")
83
+ if env_path:
84
+ self._verify_node(env_path)
85
+ return env_path
86
+
87
+ # PATH lookup
88
+ node = shutil.which("node")
89
+ if node is None:
90
+ raise RuntimeError(
91
+ "node is not installed or not on PATH. "
92
+ "Install Node.js >= 20 or set AIMOCK_NODE_PATH."
93
+ )
94
+ self._verify_node(node)
95
+ return node
96
+
97
+ # ── internal ────────────────────────────────────────────────────────
98
+
99
+ def _cache_dir(self) -> Path:
100
+ """``~/.cache/aimock/{version}/`` or ``AIMOCK_CACHE_DIR`` env."""
101
+ base: Path
102
+ if self._custom_cache_dir:
103
+ base = self._custom_cache_dir
104
+ else:
105
+ env = os.environ.get("AIMOCK_CACHE_DIR")
106
+ if env:
107
+ base = Path(env)
108
+ else:
109
+ base = Path.home() / ".cache" / "aimock"
110
+ return base / self.version
111
+
112
+ def _package_dir(self) -> Path:
113
+ """The extracted package directory inside the cache."""
114
+ return self._cache_dir() / "package"
115
+
116
+ def _download_tarball(self) -> None:
117
+ """Download the @copilotkit/aimock tarball from the npm registry and
118
+ extract it into the cache directory.
119
+
120
+ Uses file-locking (``fcntl.flock``) so parallel pytest-xdist workers
121
+ don't race. On Windows where fcntl is unavailable, locking is skipped.
122
+ """
123
+ cache = self._cache_dir()
124
+ cache.mkdir(parents=True, exist_ok=True)
125
+
126
+ _fcntl = fcntl # local binding for type narrowing
127
+ if _fcntl is not None:
128
+ lock_path = cache / ".lock"
129
+ lock_fd = open(lock_path, "w")
130
+ _fcntl.flock(lock_fd, _fcntl.LOCK_EX)
131
+ else:
132
+ lock_fd = None
133
+
134
+ try:
135
+ # Double-check after acquiring lock — another worker may have
136
+ # finished the download while we waited.
137
+ if (self._package_dir() / "dist" / "cli.js").is_file():
138
+ return
139
+
140
+ url = self._NPM_TARBALL_URL.format(version=self.version)
141
+ resp = requests.get(url, timeout=60, stream=True)
142
+ resp.raise_for_status()
143
+
144
+ with tempfile.NamedTemporaryFile(suffix=".tgz", delete=False) as tmp:
145
+ for chunk in resp.iter_content(chunk_size=65536):
146
+ tmp.write(chunk)
147
+ tmp_path = tmp.name
148
+
149
+ try:
150
+ # npm tarballs always contain a top-level ``package/`` dir.
151
+ with tarfile.open(tmp_path, "r:gz") as tar:
152
+ if sys.version_info >= (3, 12):
153
+ tar.extractall(path=str(cache), filter="data") # type: ignore[call-overload]
154
+ else:
155
+ tar.extractall(path=str(cache))
156
+ finally:
157
+ os.unlink(tmp_path)
158
+ finally:
159
+ if lock_fd is not None and _fcntl is not None:
160
+ _fcntl.flock(lock_fd, _fcntl.LOCK_UN)
161
+ lock_fd.close()
162
+
163
+ def _verify_node(self, node_path: str) -> None:
164
+ """Verify the node binary exists and is >= the minimum version."""
165
+ try:
166
+ result = subprocess.run(
167
+ [node_path, "--version"],
168
+ capture_output=True,
169
+ text=True,
170
+ timeout=10,
171
+ )
172
+ except FileNotFoundError:
173
+ raise RuntimeError(f"node binary not found at {node_path}")
174
+ except subprocess.TimeoutExpired:
175
+ raise RuntimeError(f"node --version timed out for {node_path}")
176
+
177
+ version_str = result.stdout.strip()
178
+ match = re.match(r"v(\d+)\.", version_str)
179
+ if not match:
180
+ raise RuntimeError(
181
+ f"Could not parse node version from: {version_str!r}"
182
+ )
183
+
184
+ major = int(match.group(1))
185
+ if major < self._MIN_NODE_MAJOR:
186
+ raise RuntimeError(
187
+ f"Node.js >= {self._MIN_NODE_MAJOR} required, "
188
+ f"found {version_str} at {node_path}"
189
+ )
@@ -0,0 +1,270 @@
1
+ """Manages the aimock subprocess and communicates via the /__aimock/* control API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import requests
15
+
16
+ from aimock_pytest._node_manager import NodeManager
17
+
18
+
19
+ class AIMockServer:
20
+ """Wraps a running aimock Node.js process and exposes the control API as
21
+ Python methods."""
22
+
23
+ def __init__(
24
+ self,
25
+ node_manager: NodeManager,
26
+ port: int = 0,
27
+ fixtures_path: str | Path | None = None,
28
+ ) -> None:
29
+ self.node_manager = node_manager
30
+ self.port = port
31
+ self.fixtures_path = fixtures_path
32
+ self._proc: subprocess.Popen[str] | None = None
33
+ self._base_url: str | None = None
34
+
35
+ # ── lifecycle ───────────────────────────────────────────────────────
36
+
37
+ def start(self) -> str:
38
+ """Start the aimock subprocess, wait for it to be ready, and return
39
+ the base URL (e.g. ``http://127.0.0.1:54321``)."""
40
+ env_cli = os.environ.get("AIMOCK_CLI_PATH")
41
+ if env_cli:
42
+ cli_path = Path(env_cli)
43
+ if not cli_path.is_file():
44
+ raise RuntimeError(
45
+ f"AIMOCK_CLI_PATH is set to {env_cli!r} but the file does not exist"
46
+ )
47
+ else:
48
+ cli_path = self.node_manager.ensure_installed()
49
+ node = self.node_manager.find_node()
50
+
51
+ # The CLI requires a valid fixtures path (exits 1 if not found).
52
+ # Use the provided path, or create an empty temp directory.
53
+ if self.fixtures_path:
54
+ fixtures_arg = str(self.fixtures_path)
55
+ else:
56
+ import tempfile
57
+
58
+ self._tmp_fixtures = tempfile.mkdtemp(prefix="aimock-fixtures-")
59
+ fixtures_arg = self._tmp_fixtures
60
+
61
+ cmd = [
62
+ node,
63
+ str(cli_path),
64
+ "--port",
65
+ str(self.port),
66
+ "--log-level",
67
+ "info",
68
+ "--fixtures",
69
+ fixtures_arg,
70
+ ]
71
+
72
+ self._proc = subprocess.Popen(
73
+ cmd,
74
+ stdout=subprocess.PIPE,
75
+ stderr=subprocess.STDOUT,
76
+ text=True,
77
+ )
78
+ atexit.register(self.stop)
79
+
80
+ self._base_url = self._wait_for_ready(timeout=15)
81
+ return self._base_url
82
+
83
+ def stop(self) -> None:
84
+ """Terminate the aimock subprocess."""
85
+ if self._proc is not None:
86
+ try:
87
+ self._proc.terminate()
88
+ self._proc.wait(timeout=5)
89
+ except subprocess.TimeoutExpired:
90
+ self._proc.kill()
91
+ self._proc.wait()
92
+ except Exception:
93
+ try:
94
+ self._proc.kill()
95
+ except Exception:
96
+ pass
97
+ finally:
98
+ self._proc = None
99
+ # Clean up temp fixtures directory if we created one
100
+ if hasattr(self, "_tmp_fixtures") and self._tmp_fixtures:
101
+ import shutil
102
+
103
+ shutil.rmtree(self._tmp_fixtures, ignore_errors=True)
104
+ self._tmp_fixtures = None
105
+ atexit.unregister(self.stop)
106
+
107
+ @property
108
+ def base_url(self) -> str:
109
+ """The base URL of the running aimock server."""
110
+ if self._base_url is None:
111
+ raise RuntimeError("Server has not been started yet")
112
+ return self._base_url
113
+
114
+ @property
115
+ def url(self) -> str:
116
+ """Alias for :attr:`base_url`."""
117
+ return self.base_url
118
+
119
+ # ── control API methods ─────────────────────────────────────────────
120
+
121
+ def add_fixture(
122
+ self,
123
+ match: dict[str, Any],
124
+ response: dict[str, Any],
125
+ **opts: Any,
126
+ ) -> None:
127
+ """Add a single fixture via ``POST /__aimock/fixtures``."""
128
+ fixture: dict[str, Any] = {"match": match, "response": response}
129
+ if opts:
130
+ fixture["opts"] = opts
131
+ r = requests.post(
132
+ f"{self.base_url}/__aimock/fixtures",
133
+ json={"fixtures": [fixture]},
134
+ timeout=5,
135
+ )
136
+ r.raise_for_status()
137
+
138
+ def on_message(
139
+ self,
140
+ pattern: str,
141
+ response: dict[str, Any],
142
+ **opts: Any,
143
+ ) -> AIMockServer:
144
+ """Convenience: add a fixture matching ``userMessage``."""
145
+ self.add_fixture({"userMessage": pattern}, response, **opts)
146
+ return self
147
+
148
+ def on_embedding(
149
+ self,
150
+ pattern: str,
151
+ response: dict[str, Any],
152
+ ) -> AIMockServer:
153
+ """Convenience: add a fixture matching ``inputText``."""
154
+ self.add_fixture({"inputText": pattern}, response)
155
+ return self
156
+
157
+ def load_fixtures(self, path: str | Path) -> AIMockServer:
158
+ """Read a JSON fixture file and POST its contents to the control API.
159
+
160
+ The file must contain either:
161
+ - A JSON object with a ``"fixtures"`` key (list of fixtures)
162
+ - A JSON array of fixture objects
163
+ - A single fixture object (wrapped into a list automatically)
164
+
165
+ Raises :class:`ValueError` if the parsed JSON is not a dict or list.
166
+ """
167
+ with open(path) as f:
168
+ data = json.load(f)
169
+
170
+ if isinstance(data, list):
171
+ fixtures = data
172
+ elif isinstance(data, dict) and "fixtures" in data:
173
+ fixtures = data["fixtures"]
174
+ elif isinstance(data, dict):
175
+ fixtures = [data]
176
+ else:
177
+ raise ValueError(
178
+ f"Invalid fixture file {path}: expected a JSON object or array, "
179
+ f"got {type(data).__name__}"
180
+ )
181
+
182
+ r = requests.post(
183
+ f"{self.base_url}/__aimock/fixtures",
184
+ json={"fixtures": fixtures},
185
+ timeout=5,
186
+ )
187
+ r.raise_for_status()
188
+ return self
189
+
190
+ def clear_fixtures(self) -> AIMockServer:
191
+ """Delete all fixtures via ``DELETE /__aimock/fixtures``."""
192
+ requests.delete(
193
+ f"{self.base_url}/__aimock/fixtures", timeout=5
194
+ ).raise_for_status()
195
+ return self
196
+
197
+ def reset(self) -> AIMockServer:
198
+ """Clear fixtures, journal, and match counts via ``POST /__aimock/reset``."""
199
+ requests.post(
200
+ f"{self.base_url}/__aimock/reset", timeout=5
201
+ ).raise_for_status()
202
+ return self
203
+
204
+ def get_journal(self) -> list[dict[str, Any]]:
205
+ """Return all recorded journal entries."""
206
+ r = requests.get(f"{self.base_url}/__aimock/journal", timeout=5)
207
+ r.raise_for_status()
208
+ return r.json() # type: ignore[no-any-return]
209
+
210
+ def get_last_request(self) -> dict[str, Any] | None:
211
+ """Return the most recent journal entry, or ``None``."""
212
+ journal = self.get_journal()
213
+ return journal[-1] if journal else None
214
+
215
+ def next_error(
216
+ self,
217
+ status: int,
218
+ body: dict[str, Any] | None = None,
219
+ ) -> AIMockServer:
220
+ """Queue a one-shot error via ``POST /__aimock/error``."""
221
+ requests.post(
222
+ f"{self.base_url}/__aimock/error",
223
+ json={"status": status, "body": body or {}},
224
+ timeout=5,
225
+ ).raise_for_status()
226
+ return self
227
+
228
+ # ── internal ────────────────────────────────────────────────────────
229
+
230
+ def _wait_for_ready(self, timeout: int = 15) -> str:
231
+ """Read stdout lines until we see the listening URL, then verify via
232
+ health check."""
233
+ assert self._proc is not None
234
+ assert self._proc.stdout is not None
235
+
236
+ deadline = time.monotonic() + timeout
237
+ while time.monotonic() < deadline:
238
+ # Check if process exited
239
+ if self._proc.poll() is not None:
240
+ remaining = ""
241
+ if self._proc.stdout:
242
+ remaining = self._proc.stdout.read()
243
+ raise RuntimeError(
244
+ f"aimock process exited with code {self._proc.returncode}"
245
+ f"{': ' + remaining if remaining else ''}"
246
+ )
247
+
248
+ line = self._proc.stdout.readline()
249
+ if not line:
250
+ continue
251
+
252
+ m = re.search(r"listening on (http://\S+)", line)
253
+ if m:
254
+ url = m.group(1).rstrip("/")
255
+ # Verify health endpoint is reachable
256
+ for _ in range(30):
257
+ try:
258
+ r = requests.get(
259
+ f"{url}/__aimock/health", timeout=0.5
260
+ )
261
+ if r.status_code == 200:
262
+ return url
263
+ time.sleep(0.1)
264
+ except requests.RequestException:
265
+ time.sleep(0.1)
266
+ raise RuntimeError(
267
+ "aimock started but health check failed after 3 seconds"
268
+ )
269
+
270
+ raise RuntimeError(f"aimock did not start within {timeout}s")
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the aimock package version."""
2
+
3
+ AIMOCK_VERSION = "1.7.0"
@@ -0,0 +1,55 @@
1
+ """pytest plugin — registers ``aimock`` and ``aimock_session`` fixtures.
2
+
3
+ Auto-discovered by pytest via the ``pytest11`` entry point in pyproject.toml.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+
10
+ from aimock_pytest._version import AIMOCK_VERSION
11
+ from aimock_pytest._node_manager import NodeManager
12
+ from aimock_pytest._server import AIMockServer
13
+
14
+
15
+ def pytest_addoption(parser: pytest.Parser) -> None:
16
+ group = parser.getgroup("aimock", "aimock mock server options")
17
+ group.addoption(
18
+ "--aimock-node",
19
+ default=None,
20
+ help="Path to node binary (default: auto-detect from PATH)",
21
+ )
22
+ group.addoption(
23
+ "--aimock-version",
24
+ default=AIMOCK_VERSION,
25
+ help=f"aimock npm package version to use (default: {AIMOCK_VERSION})",
26
+ )
27
+
28
+
29
+ @pytest.fixture(scope="session")
30
+ def _aimock_node_manager(request: pytest.FixtureRequest) -> NodeManager:
31
+ """Session-scoped :class:`NodeManager` — shared across all fixtures."""
32
+ return NodeManager(
33
+ version=request.config.getoption("--aimock-version"),
34
+ node_path=request.config.getoption("--aimock-node"),
35
+ )
36
+
37
+
38
+ @pytest.fixture
39
+ def aimock(_aimock_node_manager: NodeManager) -> AIMockServer:
40
+ """Function-scoped aimock server. A fresh server is started for every
41
+ test that requests this fixture, and torn down afterwards."""
42
+ server = AIMockServer(_aimock_node_manager, port=0)
43
+ server.start()
44
+ yield server # type: ignore[misc]
45
+ server.stop()
46
+
47
+
48
+ @pytest.fixture(scope="session")
49
+ def aimock_session(_aimock_node_manager: NodeManager) -> AIMockServer:
50
+ """Session-scoped aimock server. One server is shared across all tests
51
+ that request this fixture."""
52
+ server = AIMockServer(_aimock_node_manager, port=0)
53
+ server.start()
54
+ yield server # type: ignore[misc]
55
+ server.stop()
File without changes
@@ -0,0 +1,14 @@
1
+ # The aimock plugin is auto-registered via the pytest11 entry point.
2
+ # This file exists so pytest recognises this directory as a test root.
3
+
4
+ import os
5
+ import pathlib
6
+
7
+ # Auto-detect local repo build for development. If AIMOCK_CLI_PATH is not
8
+ # already set and a built cli.js exists at the repo root, use it directly
9
+ # so tests run against the local build instead of downloading from npm.
10
+ REPO_ROOT = pathlib.Path(__file__).parent.parent.parent.parent
11
+ LOCAL_CLI = REPO_ROOT / "dist" / "cli.js"
12
+
13
+ if LOCAL_CLI.exists() and "AIMOCK_CLI_PATH" not in os.environ:
14
+ os.environ["AIMOCK_CLI_PATH"] = str(LOCAL_CLI)
@@ -0,0 +1,8 @@
1
+ {
2
+ "fixtures": [
3
+ {
4
+ "match": { "userMessage": "hello" },
5
+ "response": { "content": "Hello from aimock!" }
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,69 @@
1
+ import requests
2
+
3
+ # ── Session-scoped fixture tests ──────────────────────────────────────────
4
+ # These two tests share a single aimock_session instance, verifying that the
5
+ # session-scoped fixture persists state across test functions.
6
+
7
+ _session_url: str | None = None
8
+
9
+
10
+ def test_session_fixture_starts(aimock_session):
11
+ """aimock_session starts and its URL persists across tests."""
12
+ global _session_url
13
+ r = requests.get(f"{aimock_session.base_url}/__aimock/health")
14
+ assert r.status_code == 200
15
+ _session_url = aimock_session.base_url
16
+
17
+
18
+ def test_session_fixture_persists(aimock_session):
19
+ """aimock_session is the same server instance as the previous test."""
20
+ # The base_url should be identical — same process, same port.
21
+ assert aimock_session.base_url == _session_url
22
+
23
+ # Fixtures added in a previous test would still be present (session scope
24
+ # does NOT auto-reset between tests). Verify the server is still alive.
25
+ r = requests.get(f"{aimock_session.base_url}/__aimock/health")
26
+ assert r.status_code == 200
27
+
28
+
29
+ # ── Function-scoped fixture tests ────────────────────────────────────────
30
+
31
+
32
+ def test_server_starts(aimock):
33
+ """Server starts and health check works."""
34
+ r = requests.get(f"{aimock.base_url}/__aimock/health")
35
+ assert r.status_code == 200
36
+ assert r.json()["status"] == "ok"
37
+
38
+
39
+ def test_add_fixture_and_match(aimock):
40
+ """Add a fixture via control API, then hit it."""
41
+ aimock.on_message("hello", {"content": "Hi there!"})
42
+
43
+ r = requests.post(
44
+ f"{aimock.base_url}/v1/chat/completions",
45
+ json={
46
+ "model": "gpt-4",
47
+ "messages": [{"role": "user", "content": "hello"}],
48
+ },
49
+ )
50
+ assert r.status_code == 200
51
+ body = r.json()
52
+ assert body["choices"][0]["message"]["content"] == "Hi there!"
53
+
54
+
55
+ def test_reset_clears_fixtures(aimock):
56
+ """Reset clears fixtures and journal."""
57
+ aimock.on_message("test", {"content": "response"})
58
+ aimock.reset()
59
+
60
+ r = requests.post(
61
+ f"{aimock.base_url}/v1/chat/completions",
62
+ json={
63
+ "model": "gpt-4",
64
+ "messages": [{"role": "user", "content": "test"}],
65
+ },
66
+ )
67
+ # aimock returns 404 when no fixture matches the request, confirming
68
+ # that the previously-registered fixture was cleared by reset().
69
+ assert r.status_code == 404
@@ -0,0 +1,60 @@
1
+ import os
2
+
3
+ import requests
4
+
5
+
6
+ def test_load_fixture_file(aimock):
7
+ """Load fixtures from a JSON file."""
8
+ fixture_path = os.path.join(os.path.dirname(__file__), "fixtures", "hello.json")
9
+ aimock.load_fixtures(fixture_path)
10
+
11
+ r = requests.post(
12
+ f"{aimock.base_url}/v1/chat/completions",
13
+ json={
14
+ "model": "gpt-4",
15
+ "messages": [{"role": "user", "content": "hello"}],
16
+ },
17
+ )
18
+ assert r.status_code == 200
19
+ assert "Hello from aimock" in r.json()["choices"][0]["message"]["content"]
20
+
21
+
22
+ def test_journal_records_requests(aimock):
23
+ """Journal captures all requests."""
24
+ aimock.on_message("journal-test", {"content": "ok"})
25
+
26
+ requests.post(
27
+ f"{aimock.base_url}/v1/chat/completions",
28
+ json={
29
+ "model": "gpt-4",
30
+ "messages": [{"role": "user", "content": "journal-test"}],
31
+ },
32
+ )
33
+
34
+ journal = aimock.get_journal()
35
+ assert len(journal) >= 1
36
+
37
+
38
+ def test_one_shot_error(aimock):
39
+ """Queue a one-shot error."""
40
+ aimock.on_message("error-test", {"content": "ok"})
41
+ aimock.next_error(429, {"message": "Rate limited", "type": "rate_limit_error"})
42
+
43
+ r1 = requests.post(
44
+ f"{aimock.base_url}/v1/chat/completions",
45
+ json={
46
+ "model": "gpt-4",
47
+ "messages": [{"role": "user", "content": "error-test"}],
48
+ },
49
+ )
50
+ assert r1.status_code == 429
51
+
52
+ # Second request should succeed
53
+ r2 = requests.post(
54
+ f"{aimock.base_url}/v1/chat/completions",
55
+ json={
56
+ "model": "gpt-4",
57
+ "messages": [{"role": "user", "content": "error-test"}],
58
+ },
59
+ )
60
+ assert r2.status_code == 200