From aff2cba53119385a7767a27ea84d4f2cb87201f3 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 19:01:48 -0700 Subject: [PATCH 1/7] test(control-api): red proof for the reset deprecation direction and reset() store coverage --- src/__tests__/control-api.test.ts | 51 +++++++++++++- src/__tests__/llmock.test.ts | 113 ++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/__tests__/control-api.test.ts b/src/__tests__/control-api.test.ts index 7a4c1969..be42ce6f 100644 --- a/src/__tests__/control-api.test.ts +++ b/src/__tests__/control-api.test.ts @@ -11,7 +11,7 @@ function httpRequest( url: string, method: string, body?: object, -): Promise<{ status: number; body: string }> { +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { return new Promise((resolve, reject) => { const parsed = new URL(url); const opts: http.RequestOptions = { @@ -27,6 +27,7 @@ function httpRequest( res.on("end", () => resolve({ status: res.statusCode ?? 0, + headers: res.headers, body: Buffer.concat(chunks).toString(), }), ); @@ -282,6 +283,54 @@ describe("/__aimock control API", () => { }); }); + describe("full-reset deprecation direction", () => { + it("POST /__aimock/reset is canonical — success body carries no deprecation signal", async () => { + const fixtures: Fixture[] = [ + { match: { userMessage: "hello" }, response: { content: "Hi" } }, + ]; + instance = await createServer(fixtures); + await httpRequest(`${instance.url}/v1/chat/completions`, "POST", chatRequest("hello")); + expect(instance.journal.size).toBeGreaterThan(0); + + const res = await httpRequest(`${instance.url}/__aimock/reset`, "POST"); + expect(res.status).toBe(200); + expect(res.headers.deprecation).toBeUndefined(); + expect(JSON.parse(res.body)).toEqual({ reset: true }); + expect(fixtures.length).toBe(0); + expect(instance.journal.size).toBe(0); + }); + + it("POST /__aimock/reset/fixtures is the deprecated alias — signals deprecation and still full-resets", async () => { + const fixtures: Fixture[] = [ + { match: { userMessage: "hello" }, response: { content: "Hi" } }, + ]; + instance = await createServer(fixtures); + await httpRequest(`${instance.url}/v1/chat/completions`, "POST", chatRequest("hello")); + expect(instance.journal.size).toBeGreaterThan(0); + expect(instance.journal.getFixtureMatchCount(fixtures[0])).toBeGreaterThan(0); + + const res = await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST"); + expect(res.status).toBe(200); + expect(res.headers.deprecation).toBe("true"); + + const body = JSON.parse(res.body) as { + reset: boolean; + deprecated: boolean; + deprecation: string; + }; + expect(body.reset).toBe(true); + expect(body.deprecated).toBe(true); + expect(typeof body.deprecation).toBe("string"); + // Points callers at the canonical route, not back at itself. + expect(body.deprecation).toContain("POST /__aimock/reset"); + expect(body.deprecation).not.toContain("use POST /__aimock/reset/fixtures"); + + // Back-compat: the alias still performs the same full reset. + expect(fixtures.length).toBe(0); + expect(instance.journal.size).toBe(0); + }); + }); + describe("DELETE /v1/_requests", () => { it("clears journal entries while preserving fixture match-counts", async () => { const fixtures: Fixture[] = [ diff --git a/src/__tests__/llmock.test.ts b/src/__tests__/llmock.test.ts index b797c031..6bfbb280 100644 --- a/src/__tests__/llmock.test.ts +++ b/src/__tests__/llmock.test.ts @@ -1041,6 +1041,119 @@ describe("LLMock", () => { // clearFixtures alone should not throw before start expect(mock.clearFixtures()).toBe(mock); }); + + // reset() must be the in-process equivalent of POST /__aimock/reset. These + // exercise the stores the in-process path used to leave behind. + it("clears Veo and Grok video job state — pre-reset poll ids stop resolving", async () => { + mock = new LLMock(); + mock.addFixture({ + match: { userMessage: "veo clip", endpoint: "video" }, + response: { + video: { id: "veo_reset", status: "completed", url: "https://files.example/v.mp4" }, + }, + }); + mock.addFixture({ + match: { userMessage: "grok clip", endpoint: "video" }, + response: { + video: { id: "vid_grok_reset", status: "completed", url: "https://cdn.x.ai/v.mp4" }, + }, + }); + await mock.start(); + + const veoSubmit = (await ( + await fetch(`${mock.url}/v1beta/models/veo-3.1-generate-preview:predictLongRunning`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "veo clip" }] }), + }) + ).json()) as { name: string }; + expect(typeof veoSubmit.name).toBe("string"); + + const grokSubmit = (await ( + await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "grok clip" }), + }) + ).json()) as { request_id: string }; + expect(typeof grokSubmit.request_id).toBe("string"); + + // Both jobs resolve while they are still in the maps. + expect((await fetch(`${mock.url}/v1beta/${veoSubmit.name}`)).status).toBe(200); + expect((await fetch(`${mock.url}/v1/videos/${grokSubmit.request_id}`)).status).toBe(200); + + mock.reset(); + + expect((await fetch(`${mock.url}/v1beta/${veoSubmit.name}`)).status).toBe(404); + expect((await fetch(`${mock.url}/v1/videos/${grokSubmit.request_id}`)).status).toBe(404); + }); + + it("rewinds the Gemini interaction-id counter", async () => { + mock = new LLMock(); + mock.onMessage("hello", { content: "Hi there!" }); + await mock.start(); + + const first = JSON.parse( + ( + await postTo(mock.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }) + ).data, + ) as { id: string }; + const second = JSON.parse( + ( + await postTo(mock.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }) + ).data, + ) as { id: string }; + // The counter really did advance, so a rewind is observable. + expect(first.id).not.toBe(second.id); + + mock.reset(); + mock.onMessage("hello", { content: "Hi there!" }); + + const afterReset = JSON.parse( + ( + await postTo(mock.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }) + ).data, + ) as { id: string }; + expect(afterReset.id).toBe("aimock-int-0"); + }); + + it("rewinds the Gemini interactions event-id counter", async () => { + mock = new LLMock(); + mock.onMessage("hello", { content: "Hi there!" }); + await mock.start(); + + // Burn some event ids on a streaming interaction. + await postTo(mock.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: true, + }); + + mock.reset(); + mock.onMessage("hello", { content: "Hi there!" }); + + const res = await postTo(mock.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: true, + }); + const firstEventLine = res.data.split("\n").find((l) => l.startsWith("data: ")); + expect(firstEventLine).toBeDefined(); + const firstEvent = JSON.parse(firstEventLine!.slice(6)) as { event_id: string }; + expect(firstEvent.event_id).toBe("evt_1"); + }); }); describe("baseUrl getter", () => { From 71ef4f820b20775888c86c7f2ecac90895e6de92 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 19:04:28 -0700 Subject: [PATCH 2/7] fix(control-api): make POST /__aimock/reset canonical and deprecate the /reset/fixtures alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full reset was reachable under two names, and the deprecation pointed at the dishonest one: /reset/fixtures clears fixtures, journal entries, fixture match-counts, video and fal job state, and the Gemini counters. /reset is now the canonical route; /reset/fixtures keeps working unchanged but carries the Deprecation header, the deprecated/deprecation body fields, and a log warning. performFixturesReset is renamed performFullReset and exported, and LLMock.reset() now calls it — previously the in-process reset left veoVideoJobs, grokVideoJobs and the Gemini interaction/event-id counters populated. --- src/__tests__/control-api.test.ts | 6 +- src/llmock.ts | 28 +++++---- src/openrouter-video.ts | 9 ++- src/server.ts | 100 ++++++++++++++++-------------- 4 files changed, 79 insertions(+), 64 deletions(-) diff --git a/src/__tests__/control-api.test.ts b/src/__tests__/control-api.test.ts index be42ce6f..dfde7111 100644 --- a/src/__tests__/control-api.test.ts +++ b/src/__tests__/control-api.test.ts @@ -220,7 +220,7 @@ describe("/__aimock control API", () => { const res = await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST"); expect(res.status).toBe(200); - expect(JSON.parse(res.body)).toEqual({ reset: true }); + expect(JSON.parse(res.body)).toMatchObject({ reset: true }); expect(fixtures.length).toBe(0); expect(instance.journal.size).toBe(0); }); @@ -268,13 +268,13 @@ describe("/__aimock control API", () => { expect(instance.journal.getFixtureMatchCount(fixtures[0])).toBe(countBefore); }); - it("POST /__aimock/reset is a deprecated alias that still performs a full reset", async () => { + it("POST /__aimock/reset/fixtures is a deprecated alias that still performs a full reset", async () => { const fixtures: Fixture[] = [ { match: { userMessage: "hello" }, response: { content: "Hi" } }, ]; instance = await createServer(fixtures); - const res = await httpRequest(`${instance.url}/__aimock/reset`, "POST"); + const res = await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST"); expect(res.status).toBe(200); const body = JSON.parse(res.body); expect(body).toMatchObject({ reset: true, deprecated: true }); diff --git a/src/llmock.ts b/src/llmock.ts index fe6b840d..00e49841 100644 --- a/src/llmock.ts +++ b/src/llmock.ts @@ -17,7 +17,12 @@ import type { TranscriptionResponse, VideoResponse, } from "./types.js"; -import { createServer, createServerWithResolvedAuth, type ServerInstance } from "./server.js"; +import { + createServer, + createServerWithResolvedAuth, + performFullReset, + type ServerInstance, +} from "./server.js"; import type { ResolvedInboundAuth } from "./api-key-auth.js"; import { loadFixtureFile, @@ -30,8 +35,7 @@ import { Journal } from "./journal.js"; import type { SearchFixture, SearchResult } from "./search.js"; import type { RerankFixture, RerankResult } from "./rerank.js"; import type { ModerationFixture, ModerationResult } from "./moderation.js"; -import { falJobs } from "./fal-audio.js"; -import { falQueueStates, imageResponseToFalJson, videoResponseToFalJson } from "./fal.js"; +import { imageResponseToFalJson, videoResponseToFalJson } from "./fal.js"; export class LLMock { private fixtures: Fixture[] = []; @@ -422,18 +426,20 @@ export class LLMock { // ---- Reset ---- + /** + * Full reset — the in-process equivalent of `POST /__aimock/reset`. Shares + * one implementation with the control-API route so the two cannot drift. + * + * The one deliberate difference: search / rerank / moderation fixtures are + * also cleared here. Those are registered through this class only — the + * control API has no route that creates them, so the HTTP reset can neither + * reach nor observe them. + */ reset(): this { - this.clearFixtures(); this.searchFixtures.length = 0; this.rerankFixtures.length = 0; this.moderationFixtures.length = 0; - falJobs.clear(); - falQueueStates.clear(); - if (this.serverInstance) { - this.serverInstance.journal.clear(); - this.serverInstance.videoStates.clear(); - this.serverInstance.openRouterVideoJobs.clear(); - } + performFullReset(this.fixtures, this.serverInstance); return this; } diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index a24c612f..2c8ce37b 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -2504,11 +2504,10 @@ async function captureOpenRouterVideoRecordFixture(args: { ...(capturedB64 !== undefined ? { b64: capturedB64 } : {}), ...(cost !== undefined ? { cost } : {}), }; - // World-generation guard: a fixtures reset (POST - // /__aimock/reset/fixtures) landing during the multi-second capture - // above clears BOTH the fixtures array and the job map - // (performFixturesReset) — so map identity is a valid proxy for "same - // world": if `jobs.get(key)` no longer returns this job, the world this + // World-generation guard: a full reset (POST /__aimock/reset) landing + // during the multi-second capture above clears BOTH the fixtures array + // and the job map (performFullReset) — so map identity is a valid proxy + // for "same world": if `jobs.get(key)` no longer returns this job, the world this // capture belongs to is gone and persisting would push a stale fixture // into the NEXT world's array (and write a file the new world never // asked for). Checked immediately before persistFixture with no await diff --git a/src/server.ts b/src/server.ts index f746ae21..7a77f46f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -272,32 +272,46 @@ function handleNotFound(res: http.ServerResponse, message: string): void { const CONTROL_PREFIX = "/__aimock"; /** - * Perform a full fixtures reset: clear the fixtures array, journal, video/fal - * generation state, and the interaction/event-id counters, then zero the - * `aimock_fixtures_loaded` gauge. Shared by `/reset/fixtures` and the - * deprecated `/reset` alias. + * The per-server state a full reset clears. `ServerInstance` structurally + * satisfies this, so `LLMock.reset()` and the control-API full-reset route + * share a single definition of "everything" instead of two lists that drift. */ -function performFixturesReset( - fixtures: Fixture[], - journal: Journal, - videoStates: VideoStateMap, - openRouterVideoJobs: OpenRouterVideoJobMap, - veoVideoJobs: VeoVideoJobMap, - grokVideoJobs: GrokVideoJobMap, - defaults: HandlerDefaults, -): void { +export interface FullResetTargets { + journal: Journal; + videoStates: VideoStateMap; + openRouterVideoJobs: OpenRouterVideoJobMap; + veoVideoJobs: VeoVideoJobMap; + grokVideoJobs: GrokVideoJobMap; + defaults: HandlerDefaults; +} + +/** + * Perform a full reset: clear the fixtures array, the journal (entries *and* + * per-test fixture match-counts, i.e. sequence position), the video and fal.ai + * job/queue state, and the Gemini interaction/event-id counters, then re-zero + * the `aimock_fixtures_loaded` gauge. + * + * `targets` is `null` when no server is running (an in-process `reset()` before + * `start()`). The process-global generation state is reset either way, since it + * is not owned by any one server instance. + * + * Shared by `POST /__aimock/reset` (canonical), its deprecated + * `POST /__aimock/reset/fixtures` alias, and `LLMock.reset()`. + */ +export function performFullReset(fixtures: Fixture[], targets: FullResetTargets | null): void { fixtures.length = 0; - journal.clear(); - videoStates.clear(); - openRouterVideoJobs.clear(); - veoVideoJobs.clear(); - grokVideoJobs.clear(); falJobs.clear(); falQueueStates.clear(); resetInteractionCounter(); resetEventIdCounter(); - if (defaults.registry) { - defaults.registry.setGauge("aimock_fixtures_loaded", {}, fixtures.length); + if (!targets) return; + targets.journal.clear(); + targets.videoStates.clear(); + targets.openRouterVideoJobs.clear(); + targets.veoVideoJobs.clear(); + targets.grokVideoJobs.clear(); + if (targets.defaults.registry) { + targets.defaults.registry.setGauge("aimock_fixtures_loaded", {}, fixtures.length); } } @@ -395,17 +409,19 @@ async function handleControlAPI( return true; } - // POST /__aimock/reset/fixtures — full reset (fixtures + journal + match counts) - if (subPath === "/reset/fixtures" && req.method === "POST") { - performFixturesReset( - fixtures, - journal, - videoStates, - openRouterVideoJobs, - veoVideoJobs, - grokVideoJobs, - defaults, - ); + const resetTargets = (): FullResetTargets => ({ + journal, + videoStates, + openRouterVideoJobs, + veoVideoJobs, + grokVideoJobs, + defaults, + }); + + // POST /__aimock/reset — full reset (fixtures, journal entries + fixture + // match-counts, video/fal job state, Gemini counters) + if (subPath === "/reset" && req.method === "POST") { + performFullReset(fixtures, resetTargets()); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ reset: true })); return true; @@ -420,21 +436,15 @@ async function handleControlAPI( return true; } - // POST /__aimock/reset — DEPRECATED alias for /reset/fixtures (full reset) - if (subPath === "/reset" && req.method === "POST") { - performFixturesReset( - fixtures, - journal, - videoStates, - openRouterVideoJobs, - veoVideoJobs, - grokVideoJobs, - defaults, - ); + // POST /__aimock/reset/fixtures — DEPRECATED alias for /reset. The name + // promises a fixtures-only reset but it always performed the full reset; + // /reset is the honest route. Behaviour is unchanged for existing callers. + if (subPath === "/reset/fixtures" && req.method === "POST") { + performFullReset(fixtures, resetTargets()); const deprecation = - "POST /__aimock/reset is deprecated; use POST /__aimock/reset/fixtures (full reset) or POST /__aimock/reset/journal (journal only)"; + "POST /__aimock/reset/fixtures is deprecated; use POST /__aimock/reset (full reset) or POST /__aimock/reset/journal (journal only)"; defaults.logger.warn( - "POST /__aimock/reset is deprecated; use /__aimock/reset/fixtures or /__aimock/reset/journal", + "POST /__aimock/reset/fixtures is deprecated; use /__aimock/reset or /__aimock/reset/journal", ); res.writeHead(200, { "Content-Type": "application/json", Deprecation: "true" }); res.end(JSON.stringify({ reset: true, deprecated: true, deprecation })); @@ -1977,7 +1987,7 @@ export async function createServerWithResolvedAuth( // Clear only the request journal entries, preserving fixture // match-counts (sequencing state). Clearing the request log must not // silently rewind sequenced fixtures. For a full reset (entries + - // match-counts), use POST /__aimock/reset/fixtures. + // match-counts), use POST /__aimock/reset. journal.clearEntries(); res.writeHead(204); res.end(); From 70973ec82c60425c7db4824cd7a0abe36a15e6f5 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 19:20:11 -0700 Subject: [PATCH 3/7] docs(control-api): state what each reset route actually clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Route Overview table and the per-route sections now name the full reset's real blast radius — fixtures, journal entries, fixture match-counts (sequence position), video + fal.ai job state, and the Gemini interaction and event-id counters — and put the deprecation on /reset/fixtures. aimock-pytest: reset() and reset_fixtures() both call POST /__aimock/reset. Both already performed a full reset, so observable behaviour is unchanged; they just no longer trip the deprecated alias. --- CHANGELOG.md | 6 ++ docs/control-api/index.html | 67 +++++++++++-------- packages/aimock-pytest/README.md | 7 +- .../src/aimock_pytest/_server.py | 18 +++-- 4 files changed, 59 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324d133c..e8531cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Changed + +- **The full-reset deprecation now points at the honest route.** `POST /__aimock/reset` is the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/fixtures` is the deprecated alias: it promised a fixtures-only reset while always clearing everything, so it now carries the `Deprecation: true` header, the `deprecated` / `deprecation` body fields, and a log warning. Its behavior is otherwise unchanged, so existing callers (including published `aimock-pytest`) keep working — migrate to `POST /__aimock/reset`, or use `DELETE /__aimock/fixtures` for a fixtures-only clear. `POST /__aimock/reset/journal` is unaffected. +- **`LLMock.reset()` and the control-API full reset are now one implementation.** Both call a shared `performFullReset`, which clears the fixtures array, the journal (entries _and_ per-test fixture match-counts), video and fal.ai job/queue state, and the Gemini interaction/event-id counters. The in-process `reset()` previously left `veoVideoJobs`, `grokVideoJobs` and both Gemini counters populated, so an in-process test suite could see a stale Veo/Grok poll resolve after a reset and interaction ids that never restarted at `aimock-int-0`. `reset()` additionally clears the search / rerank / moderation fixtures, which are registrable only through `LLMock` and that the control API cannot reach. +- `aimock-pytest`: `AIMockServer.reset()` and `.reset_fixtures()` now call `POST /__aimock/reset`. Both already performed a full reset; the observable behavior is unchanged and neither emits a deprecation warning any more. + ## [1.38.0] - 2026-08-03 ### Added diff --git a/docs/control-api/index.html b/docs/control-api/index.html index 9a5db20b..a69f77ec 100644 --- a/docs/control-api/index.html +++ b/docs/control-api/index.html @@ -94,18 +94,21 @@

Route Overview

POST - /__aimock/reset/fixtures - Full reset: fixtures + generation state + journal + /__aimock/reset + + Full reset: fixtures, journal entries, fixture match-counts (sequence position), + video + fal.ai job state, and the Gemini interaction / event-id counters + POST /__aimock/reset/journal - Clear only the request journal + Clear only the request journal entries POST - /__aimock/reset - Deprecated. Alias for /reset/fixtures + /__aimock/reset/fixtures + Deprecated. Alias for /reset POST @@ -118,11 +121,13 @@

Route Overview

Reset Routes

aimock keeps several kinds of in-memory state between requests: the loaded - fixtures, the per-provider generation state (video, - fal.ai, and Gemini counters), the fixture match-counts (sequence - position), and the request journal (recorded requests). The reset routes - let you clear these selectively — a full reset clears everything, while a journal - reset clears only the recorded requests and leaves the rest intact. + fixtures, the per-provider generation state (video and + fal.ai jobs, plus the Gemini interaction and event-id counters), the + fixture match-counts (sequence position), and the + request journal (recorded requests). There are two reset routes: + POST /__aimock/reset clears all of it, and + POST /__aimock/reset/journal clears only the recorded requests and leaves + everything else intact.

Reset Routes margin: 1.5rem 0; " > - The footgun this split fixes — a - caller that wanted a clean journal between test runs used to call - POST /__aimock/reset and unintentionally wiped the loaded fixtures too. Every - subsequent request then returned no_fixture_match until the server was - restarted. If you only want a clean read between runs, use + Pick the narrower route — + POST /__aimock/reset wipes the loaded fixtures along with everything else, so + every subsequent request returns no_fixture_match until you load fixtures + again. If all you want is a clean read between test runs, use POST /__aimock/reset/journal — it leaves your fixtures intact.
-

POST /__aimock/reset/fixtures

+

POST /__aimock/reset

- Full reset. Clears the in-memory fixtures, the generation state (video / - fal.ai / Gemini counters), and the journal. Use this when you want the server - returned to a pristine, fixture-free state. + Full reset. Returns the server to a pristine, fixture-free state. It + clears the in-memory fixtures, the journal entries and the per-test fixture + match-counts (so sequenced fixtures rewind to their first response), the video and fal.ai + job and queue state, and the Gemini interaction and event-id counters.

Full reset shell
-
$ curl -X POST http://localhost:4010/__aimock/reset/fixtures
+
$ curl -X POST http://localhost:4010/__aimock/reset
Response json
@@ -177,17 +182,21 @@

POST /__aimock/reset/journal

{ "reset": true }
-

POST /__aimock/reset (Deprecated)

+

+ POST /__aimock/reset/fixtures (Deprecated) +

- Deprecated alias for /__aimock/reset/fixtures. It performs - the same full reset, but additionally sets a Deprecation: true response - header and adds deprecated / deprecation fields to the body. - Prefer the explicit /reset/fixtures or /reset/journal routes - — they make the intent (and blast radius) of the reset unambiguous. + Deprecated alias for /__aimock/reset. The name promises a + fixtures-only reset, but it performs the same full reset — journal, match-counts, + job state and counters all go with it. It additionally sets a + Deprecation: true response header and adds deprecated / + deprecation fields to the body. Use /reset for a full reset, or + /reset/journal for a journal-only one; to clear fixtures and nothing else, + use DELETE /__aimock/fixtures.

Deprecated reset shell
-
$ curl -i -X POST http://localhost:4010/__aimock/reset
+
$ curl -i -X POST http://localhost:4010/__aimock/reset/fixtures
Response json
@@ -195,7 +204,7 @@

POST /__aimock/reset (Deprecated) { "reset": true, "deprecated": true, - "deprecation": "POST /__aimock/reset is deprecated; use POST /__aimock/reset/fixtures (full reset) or POST /__aimock/reset/journal (journal only)" + "deprecation": "POST /__aimock/reset/fixtures is deprecated; use POST /__aimock/reset (full reset) or POST /__aimock/reset/journal (journal only)" }

@@ -253,7 +262,7 @@

POST /__aimock/fixtures

DELETE /__aimock/fixtures

Clears all registered fixtures. Generation state and the journal are left untouched. To - clear everything at once, use /__aimock/reset/fixtures instead. + clear everything at once, use /__aimock/reset instead.

Clear fixtures shell
diff --git a/packages/aimock-pytest/README.md b/packages/aimock-pytest/README.md index c3540626..d89dabdb 100644 --- a/packages/aimock-pytest/README.md +++ b/packages/aimock-pytest/README.md @@ -69,10 +69,11 @@ aimock.get_last_request() # most recent request or None aimock.next_error(429, {"message": "Rate limited"}) # Reset -aimock.clear_fixtures() # remove all fixtures -aimock.reset_fixtures() # clear fixtures + generation state (and journal) +aimock.clear_fixtures() # remove all fixtures, nothing else +aimock.reset() # full reset: fixtures, journal entries + match-counts, + # video/fal job state, Gemini counters aimock.reset_journal() # clear only the request journal (fixtures preserved) -aimock.reset() # alias for reset_fixtures() +aimock.reset_fixtures() # alias for reset() — a full reset, despite the name ``` ## CLI Options diff --git a/packages/aimock-pytest/src/aimock_pytest/_server.py b/packages/aimock-pytest/src/aimock_pytest/_server.py index 27eaf134..bf802314 100644 --- a/packages/aimock-pytest/src/aimock_pytest/_server.py +++ b/packages/aimock-pytest/src/aimock_pytest/_server.py @@ -324,15 +324,19 @@ def clear_fixtures(self) -> AIMockServer: return self def reset(self) -> AIMockServer: - """Full reset: clear fixtures + generation state + journal (alias for - :meth:`reset_fixtures`).""" - return self.reset_fixtures() + """Full reset via ``POST /__aimock/reset``: clears fixtures, journal + entries and fixture match-counts, video/fal job state, and the Gemini + counters.""" + self._control_request("POST", "/reset", timeout=5).raise_for_status() + return self def reset_fixtures(self) -> AIMockServer: - """Clear fixtures + generation state (and journal) via - ``POST /__aimock/reset/fixtures``.""" - self._control_request("POST", "/reset/fixtures", timeout=5).raise_for_status() - return self + """Alias for :meth:`reset` — a full reset, not a fixtures-only one. + + The name is kept for compatibility; ``DELETE /__aimock/fixtures`` + (:meth:`clear_fixtures`) is the fixtures-only call. + """ + return self.reset() def reset_journal(self) -> AIMockServer: """Clear ONLY the request journal, leaving fixtures intact, via From 18969c69d7635f3a697478afa4863bc2a5c20c4c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 19:41:59 -0700 Subject: [PATCH 4/7] fix(control-api): guard the stores the full reset clears, and correct the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four stores were unguarded — dropping falJobs.clear(), falQueueStates.clear() or videoStates.clear(), or swapping journal.clear() for clearEntries(), all left the suite green. Adds a test per store, each verified to go red under its own mutation. The match-count one guards the sequence-position rewind the docs now headline. The changelog claimed the Python client no longer emits a deprecation warning. That is false while _version.py pins 1.38.0, whose /reset IS the deprecated alias; the pin bump is now recorded as a release follow-up. Also drops the unqualified 'behaviour is unchanged' for /reset/fixtures — its reset semantics are unchanged but its response body is additively extended. --- CHANGELOG.md | 6 ++- docs/control-api/index.html | 5 +- src/__tests__/llmock.test.ts | 102 +++++++++++++++++++++++++++++++++++ src/openrouter-video.ts | 4 +- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8531cf3..48c87f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ ### Changed -- **The full-reset deprecation now points at the honest route.** `POST /__aimock/reset` is the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/fixtures` is the deprecated alias: it promised a fixtures-only reset while always clearing everything, so it now carries the `Deprecation: true` header, the `deprecated` / `deprecation` body fields, and a log warning. Its behavior is otherwise unchanged, so existing callers (including published `aimock-pytest`) keep working — migrate to `POST /__aimock/reset`, or use `DELETE /__aimock/fixtures` for a fixtures-only clear. `POST /__aimock/reset/journal` is unaffected. +- **The full-reset deprecation now points at the honest route.** `POST /__aimock/reset` is the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/fixtures` is the deprecated alias: it promised a fixtures-only reset while always clearing everything, so it now carries the `Deprecation: true` header, the `deprecated` / `deprecation` body fields, and a log warning. Migrate to `POST /__aimock/reset`, or use `DELETE /__aimock/fixtures` for a fixtures-only clear. `POST /__aimock/reset/journal` is unaffected. + - The alias's **reset semantics are unchanged** — it clears exactly what it always did, so existing callers keep working. Its **response body is additively extended**: it now carries `deprecated` and `deprecation` alongside `reset`, and a `Deprecation: true` header. A caller asserting strict equality on the old `{ "reset": true }` body will need to relax that assertion; a caller reading `body.reset` is unaffected. - **`LLMock.reset()` and the control-API full reset are now one implementation.** Both call a shared `performFullReset`, which clears the fixtures array, the journal (entries _and_ per-test fixture match-counts), video and fal.ai job/queue state, and the Gemini interaction/event-id counters. The in-process `reset()` previously left `veoVideoJobs`, `grokVideoJobs` and both Gemini counters populated, so an in-process test suite could see a stale Veo/Grok poll resolve after a reset and interaction ids that never restarted at `aimock-int-0`. `reset()` additionally clears the search / rerank / moderation fixtures, which are registrable only through `LLMock` and that the control API cannot reach. -- `aimock-pytest`: `AIMockServer.reset()` and `.reset_fixtures()` now call `POST /__aimock/reset`. Both already performed a full reset; the observable behavior is unchanged and neither emits a deprecation warning any more. +- `aimock-pytest`: `AIMockServer.reset()` and `.reset_fixtures()` now call `POST /__aimock/reset`. Both already performed a full reset, so the observable behavior is unchanged. + - **This does not yet avoid the deprecation warning.** `_version.py` pins `AIMOCK_VERSION = "1.38.0"`, and on that published server `/reset` is still the deprecated alias — so the client trips a deprecation on every reset until the pin moves. **Release follow-up (required):** bump `AIMOCK_VERSION` to the first npm release containing this change before publishing the next `aimock-pytest`. Until then the client is deprecation-clean only against a server built from this branch. ## [1.38.0] - 2026-08-03 diff --git a/docs/control-api/index.html b/docs/control-api/index.html index a69f77ec..886550cd 100644 --- a/docs/control-api/index.html +++ b/docs/control-api/index.html @@ -124,10 +124,11 @@

Reset Routes

fixtures, the per-provider generation state (video and fal.ai jobs, plus the Gemini interaction and event-id counters), the fixture match-counts (sequence position), and the - request journal (recorded requests). There are two reset routes: + request journal (recorded requests). Two routes clear it: POST /__aimock/reset clears all of it, and POST /__aimock/reset/journal clears only the recorded requests and leaves - everything else intact. + everything else intact. A third, POST /__aimock/reset/fixtures, is a + deprecated alias for the full reset.

{ const firstEvent = JSON.parse(firstEventLine!.slice(6)) as { event_id: string }; expect(firstEvent.event_id).toBe("evt_1"); }); + + it("clears Sora video state — a pre-reset video id stops resolving", async () => { + mock = new LLMock(); + mock.addFixture({ + match: { userMessage: "sora clip", endpoint: "video" }, + response: { + video: { id: "video_sora_reset", status: "completed", url: "https://s/v.mp4" }, + }, + }); + await mock.start(); + + const created = (await ( + await fetch(`${mock.url}/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "sora-2", prompt: "sora clip" }), + }) + ).json()) as { id: string }; + expect(typeof created.id).toBe("string"); + expect((await fetch(`${mock.url}/v1/videos/${created.id}`)).status).toBe(200); + + mock.reset(); + + const after = await fetch(`${mock.url}/v1/videos/${created.id}`); + expect(after.status).toBe(404); + expect(((await after.json()) as { error: { type: string } }).error.type).toBe("not_found"); + }); + + it("clears fal.ai audio queue jobs — a pre-reset request_id stops resolving", async () => { + mock = new LLMock(); + mock.onFalAudio("drum loop", { audio: "SGVsbG8=", format: "mp3" }); + await mock.start(); + + const envelope = (await ( + await fetch(`${mock.url}/fal/queue/submit/fal-ai/stable-audio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "drum loop" }), + }) + ).json()) as { request_id: string }; + expect(typeof envelope.request_id).toBe("string"); + expect( + (await fetch(`${mock.url}/fal/queue/requests/${envelope.request_id}/status`)).status, + ).toBe(200); + + mock.reset(); + + expect( + (await fetch(`${mock.url}/fal/queue/requests/${envelope.request_id}/status`)).status, + ).toBe(404); + }); + + it("clears fal.ai general queue state — a pre-reset request_id stops resolving", async () => { + mock = new LLMock(); + mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/cat.png" }] }); + await mock.start(); + + const falHeaders = { "x-fal-target-host": "queue.fal.run" }; + const envelope = (await ( + await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", ...falHeaders }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }) + ).json()) as { request_id: string }; + expect(typeof envelope.request_id).toBe("string"); + const statusUrl = `${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}/status`; + expect((await fetch(statusUrl, { headers: falHeaders })).status).toBe(200); + + mock.reset(); + + expect((await fetch(statusUrl, { headers: falHeaders })).status).toBe(404); + }); + + // The full reset clears journal ENTRIES *and* per-test fixture + // match-counts. Only the latter carries sequence position, so a reset that + // used clearEntries() would leave sequenced fixtures parked mid-sequence. + it("clears fixture match-counts, rewinding sequence position", async () => { + const first = { + match: { userMessage: "seq", sequenceIndex: 0 }, + response: { content: "FIRST" }, + }; + const second = { + match: { userMessage: "seq", sequenceIndex: 1 }, + response: { content: "SECOND" }, + }; + mock = new LLMock(); + mock.addFixture(first).addFixture(second); + await mock.start(); + + expect((await post(mock.url, chatBody("seq"))).data).toContain("FIRST"); + expect((await post(mock.url, chatBody("seq"))).data).toContain("SECOND"); + expect(mock.journal.getFixtureMatchCount(first)).toBe(2); + + mock.reset(); + // Re-add the SAME fixture objects — counts are keyed by object identity, + // so a surviving count would still be attached to them. + mock.addFixture(first).addFixture(second); + + expect(mock.journal.getFixtureMatchCount(first)).toBe(0); + expect((await post(mock.url, chatBody("seq"))).data).toContain("FIRST"); + }); }); describe("baseUrl getter", () => { diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 2c8ce37b..d674148f 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -2236,8 +2236,8 @@ async function proxyOpenRouterVideoRecordPoll(args: { // upstream fetch was in flight (everything below the fetch is // synchronous, so the first poll to resume wins atomically). Relay // without persisting a duplicate fixture or re-registering it. - // This identity check ALSO covers a fixtures reset landing during the - // upstream fetch: performFixturesReset clears the job map, so a + // This identity check ALSO covers a full reset landing during the + // upstream fetch: performFullReset clears the job map, so a // cleared world fails the check and the stale failure fixture never // pollutes the next world's fixtures array — valid because everything // from here to persistFixture below is synchronous (no interleaving From 8d15b26325efcd9530995ba815b49d7d5c995026 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 20:00:34 -0700 Subject: [PATCH 5/7] test(control-api): close the vacuous, uncovered and default-scoped reset gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alias's deprecation assertion could not fail: every substring it checked is present in a message pointing back at itself. It now asserts the whole string. Three paths had no coverage at all — the search/rerank/moderation clear that is the documented in-process divergence, the pre-start path where performFullReset takes a null target, and match-counts under a non-default testId, which let a default-only clear strand every other tenant. Docs: a full reset does not always yield no_fixture_match — in record mode an unmatched request is proxied to the real provider, which costs real money. The changelog now files the route change under Deprecated, per the 1.29.0 precedent, and both it and the reset() docblock record that reset() clears MODULE-GLOBAL state, so one instance's reset rewinds another's Gemini ids. --- CHANGELOG.md | 11 +- docs/control-api/index.html | 12 +- src/__tests__/control-api.test.ts | 16 ++- src/__tests__/llmock.test.ts | 109 ++++++++++++++++++ src/__tests__/openrouter-video-record.test.ts | 4 +- src/__tests__/openrouter-video.test.ts | 29 ++++- src/llmock.ts | 13 +++ 7 files changed, 179 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c87f99..5a46743e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,16 @@ ## [Unreleased] +### Deprecated + +- `POST /__aimock/reset/fixtures` — now a deprecated alias for `POST /__aimock/reset`. The name promised a fixtures-only reset while it always performed the full reset, so `/reset` is the honest route and the deprecation moves onto the alias: it still performs the same full reset but emits a `Deprecation: true` response header, `deprecated` / `deprecation` fields in the body, and a log warning. Use `POST /__aimock/reset` for a full reset, `POST /__aimock/reset/journal` for a journal-only one, or `DELETE /__aimock/fixtures` to clear fixtures and nothing else. + - The alias's **reset semantics are unchanged** — it clears exactly what it always did, so existing callers keep working. Its **response body is additively extended**: it now carries `deprecated` and `deprecation` alongside `reset`, plus the `Deprecation: true` header. A caller asserting strict equality on the old `{ "reset": true }` body will need to relax that assertion; a caller reading `body.reset` is unaffected. + ### Changed -- **The full-reset deprecation now points at the honest route.** `POST /__aimock/reset` is the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/fixtures` is the deprecated alias: it promised a fixtures-only reset while always clearing everything, so it now carries the `Deprecation: true` header, the `deprecated` / `deprecation` body fields, and a log warning. Migrate to `POST /__aimock/reset`, or use `DELETE /__aimock/fixtures` for a fixtures-only clear. `POST /__aimock/reset/journal` is unaffected. - - The alias's **reset semantics are unchanged** — it clears exactly what it always did, so existing callers keep working. Its **response body is additively extended**: it now carries `deprecated` and `deprecation` alongside `reset`, and a `Deprecation: true` header. A caller asserting strict equality on the old `{ "reset": true }` body will need to relax that assertion; a caller reading `body.reset` is unaffected. -- **`LLMock.reset()` and the control-API full reset are now one implementation.** Both call a shared `performFullReset`, which clears the fixtures array, the journal (entries _and_ per-test fixture match-counts), video and fal.ai job/queue state, and the Gemini interaction/event-id counters. The in-process `reset()` previously left `veoVideoJobs`, `grokVideoJobs` and both Gemini counters populated, so an in-process test suite could see a stale Veo/Grok poll resolve after a reset and interaction ids that never restarted at `aimock-int-0`. `reset()` additionally clears the search / rerank / moderation fixtures, which are registrable only through `LLMock` and that the control API cannot reach. +- `POST /__aimock/reset` is now the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/journal` is unaffected. +- **`LLMock.reset()` and the control-API full reset are now one implementation.** Both call a shared `performFullReset`, which clears the fixtures array, the journal (entries _and_ per-test fixture match-counts, across every testId), video and fal.ai job/queue state, and the Gemini interaction/event-id counters. The in-process `reset()` previously left `veoVideoJobs`, `grokVideoJobs` and both Gemini counters populated, so an in-process test suite could see a stale Veo/Grok poll resolve after a reset and interaction ids that never restarted at `aimock-int-0`. `reset()` additionally clears the search / rerank / moderation fixtures, which are registrable only through `LLMock` and that the control API cannot reach. + - **Behavior change for multi-instance in-process users:** `reset()` now also clears state that is MODULE-GLOBAL, not per-instance — the Gemini interaction and event-id counters, and the fal.ai job/queue maps. With two `LLMock` instances live in one process, `a.reset()` rewinds the Gemini id sequence that instance `b` is mid-way through, so `b` re-emits `aimock-int-0` / `evt_1` — ids it has already handed out — and drops `b`'s in-flight fal jobs. The fal maps were already global before this change; the counters are newly reached. Give each instance its own process (or its own vitest worker) if that matters. - `aimock-pytest`: `AIMockServer.reset()` and `.reset_fixtures()` now call `POST /__aimock/reset`. Both already performed a full reset, so the observable behavior is unchanged. - **This does not yet avoid the deprecation warning.** `_version.py` pins `AIMOCK_VERSION = "1.38.0"`, and on that published server `/reset` is still the deprecated alias — so the client trips a deprecation on every reset until the pin moves. **Release follow-up (required):** bump `AIMOCK_VERSION` to the first npm release containing this change before publishing the next `aimock-pytest`. Until then the client is deprecation-clean only against a server built from this branch. diff --git a/docs/control-api/index.html b/docs/control-api/index.html index 886550cd..855d0cd1 100644 --- a/docs/control-api/index.html +++ b/docs/control-api/index.html @@ -143,9 +143,15 @@

Reset Routes

" > Pick the narrower route — - POST /__aimock/reset wipes the loaded fixtures along with everything else, so - every subsequent request returns no_fixture_match until you load fixtures - again. If all you want is a clean read between test runs, use + POST /__aimock/reset wipes the loaded fixtures along with everything else, + and what happens to the next request then depends on the mode. In replay mode it returns + no_fixture_match; in strict mode it returns 503; but + in record mode, with a provider key configured, an unmatched request is proxied to the + real provider + — so a full reset mid-recording means live upstream calls and real spend, not an + error. If all you want is a clean read between test runs, use POST /__aimock/reset/journal — it leaves your fixtures intact.
diff --git a/src/__tests__/control-api.test.ts b/src/__tests__/control-api.test.ts index dfde7111..0790005e 100644 --- a/src/__tests__/control-api.test.ts +++ b/src/__tests__/control-api.test.ts @@ -195,15 +195,19 @@ describe("/__aimock control API", () => { ]; instance = await createServer(fixtures); - // Make a request to populate journal + // Make a request to populate the journal AND the fixture's match-count. await httpRequest(`${instance.url}/v1/chat/completions`, "POST", chatRequest("hello")); expect(instance.journal.size).toBeGreaterThan(0); + const fixture = fixtures[0]; + expect(instance.journal.getFixtureMatchCount(fixture)).toBeGreaterThan(0); const res = await httpRequest(`${instance.url}/__aimock/reset`, "POST"); expect(res.status).toBe(200); expect(JSON.parse(res.body)).toMatchObject({ reset: true }); expect(fixtures.length).toBe(0); expect(instance.journal.size).toBe(0); + // The name of this test promises match counts — assert them. + expect(instance.journal.getFixtureMatchCount(fixture)).toBe(0); }); }); @@ -320,10 +324,12 @@ describe("/__aimock control API", () => { }; expect(body.reset).toBe(true); expect(body.deprecated).toBe(true); - expect(typeof body.deprecation).toBe("string"); - // Points callers at the canonical route, not back at itself. - expect(body.deprecation).toContain("POST /__aimock/reset"); - expect(body.deprecation).not.toContain("use POST /__aimock/reset/fixtures"); + // Points callers at the canonical route, not back at itself. Asserting + // the whole string: any substring of it that mentions "/__aimock/reset" + // is also present in a message that points back at /reset/fixtures. + expect(body.deprecation).toBe( + "POST /__aimock/reset/fixtures is deprecated; use POST /__aimock/reset (full reset) or POST /__aimock/reset/journal (journal only)", + ); // Back-compat: the alias still performs the same full reset. expect(fixtures.length).toBe(0); diff --git a/src/__tests__/llmock.test.ts b/src/__tests__/llmock.test.ts index 86833eec..98cd3452 100644 --- a/src/__tests__/llmock.test.ts +++ b/src/__tests__/llmock.test.ts @@ -1244,17 +1244,126 @@ describe("LLMock", () => { mock.addFixture(first).addFixture(second); await mock.start(); + // Counts are per-testId, so drive BOTH the default scope and a named + // one — a reset that only clears the default sentinel would strand + // every other tenant mid-sequence. + const asTenant = (msg: string) => + fetch(`${mock!.url}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-test-id": "tenant-a" }, + body: JSON.stringify(chatBody(msg, false)), + }).then((r) => r.text()); + expect((await post(mock.url, chatBody("seq"))).data).toContain("FIRST"); expect((await post(mock.url, chatBody("seq"))).data).toContain("SECOND"); expect(mock.journal.getFixtureMatchCount(first)).toBe(2); + expect(await asTenant("seq")).toContain("FIRST"); + expect(await asTenant("seq")).toContain("SECOND"); + expect(mock.journal.getFixtureMatchCount(first, "tenant-a")).toBe(2); + mock.reset(); // Re-add the SAME fixture objects — counts are keyed by object identity, // so a surviving count would still be attached to them. mock.addFixture(first).addFixture(second); expect(mock.journal.getFixtureMatchCount(first)).toBe(0); + expect(mock.journal.getFixtureMatchCount(first, "tenant-a")).toBe(0); expect((await post(mock.url, chatBody("seq"))).data).toContain("FIRST"); + expect(await asTenant("seq")).toContain("FIRST"); + }); + + // The documented divergence from the HTTP reset: these three stores are + // in-process only, so nothing but this test guards them. + it("clears search, rerank and moderation fixtures", async () => { + mock = new LLMock(); + mock.onSearch("weather", [ + { title: "Weather Report", url: "https://example.com/weather", content: "Sunny today" }, + ]); + mock.onRerank("machine learning", [{ index: 0, relevance_score: 0.99 }]); + mock.onModerate("violent", { flagged: true, categories: { violence: true } }); + await mock.start(); + + const search = async () => + JSON.parse( + (await postTo(mock!.url, "/search", { query: "What is the weather?" })).data, + ) as { + results: unknown[]; + }; + const rerank = async () => + JSON.parse( + ( + await postTo(mock!.url, "/v2/rerank", { + query: "What is machine learning?", + documents: ["ML is a subset of AI"], + model: "rerank-v3.5", + }) + ).data, + ) as { results: unknown[] }; + const moderate = async () => + JSON.parse( + (await postTo(mock!.url, "/v1/moderations", { input: "This is violent content" })).data, + ) as { results: Array<{ flagged: boolean }> }; + + expect((await search()).results).toHaveLength(1); + expect((await rerank()).results).toHaveLength(1); + expect((await moderate()).results[0].flagged).toBe(true); + + mock.reset(); + + // A cleared fixture store yields an empty/unflagged response, not an error. + expect((await search()).results).toHaveLength(0); + expect((await rerank()).results).toHaveLength(0); + expect((await moderate()).results[0].flagged).toBe(false); + }); + + // performFullReset takes a null target before start(). The process-global + // stores must still be cleared on that path. + it("clears process-global state even when called before start()", async () => { + // Seed the global fal stores and the Gemini counters through a first, + // fully-started instance, then stop it. + const seeder = new LLMock(); + seeder.onFalAudio("drum loop", { audio: "SGVsbG8=", format: "mp3" }); + seeder.onMessage("hello", { content: "Hi there!" }); + await seeder.start(); + const envelope = (await ( + await fetch(`${seeder.url}/fal/queue/submit/fal-ai/stable-audio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "drum loop" }), + }) + ).json()) as { request_id: string }; + await postTo(seeder.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }); + await seeder.stop(); + + // A brand-new, NEVER-STARTED instance resets the process-global state. + const unstarted = new LLMock(); + unstarted.onMessage("x", { content: "y" }); + unstarted.reset(); + expect(unstarted.getFixtures()).toHaveLength(0); + + // Observe through a fresh server: the seeded fal job is gone and the + // Gemini interaction counter restarted. + mock = new LLMock(); + mock.onMessage("hello", { content: "Hi there!" }); + await mock.start(); + expect( + (await fetch(`${mock.url}/fal/queue/requests/${envelope.request_id}/status`)).status, + ).toBe(404); + const interaction = JSON.parse( + ( + await postTo(mock.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }) + ).data, + ) as { id: string }; + expect(interaction.id).toBe("aimock-int-0"); }); }); diff --git a/src/__tests__/openrouter-video-record.test.ts b/src/__tests__/openrouter-video-record.test.ts index c83ba17a..c3da84b5 100644 --- a/src/__tests__/openrouter-video-record.test.ts +++ b/src/__tests__/openrouter-video-record.test.ts @@ -3927,7 +3927,7 @@ describe("OpenRouter video record — round 4 CR", () => { await waitUntil(() => upstream!.counts.content === 1); // The world resets mid-capture: fixtures array cleared, job map cleared. - const reset = await fetch(`${m.url}/__aimock/reset/fixtures`, { method: "POST" }); + const reset = await fetch(`${m.url}/__aimock/reset`, { method: "POST" }); expect(reset.status).toBe(200); await reset.arrayBuffer(); @@ -4369,7 +4369,7 @@ describe("OpenRouter video record — round 5 CR", () => { body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "reset mid submit" }), }); await sleep(150); // the upstream submit is in flight - const reset = await fetch(`${m.url}/__aimock/reset/fixtures`, { method: "POST" }); + const reset = await fetch(`${m.url}/__aimock/reset`, { method: "POST" }); expect(reset.status).toBe(200); await reset.arrayBuffer(); diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index ec87e550..68ed3d4c 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -2267,7 +2267,7 @@ describe("OpenRouter video — reset plumbing", () => { return id; } - test("POST /__aimock/reset/fixtures clears job state (old jobId polls 404)", async () => { + test("POST /__aimock/reset clears job state (old jobId polls 404)", async () => { mock = new LLMock({ port: 0 }); mock.addFixture({ match: { userMessage: "reset http", endpoint: "video" }, @@ -2280,8 +2280,33 @@ describe("OpenRouter video — reset plumbing", () => { expect(before.status).toBe(200); await before.arrayBuffer(); + const reset = await fetch(`${mock.url}/__aimock/reset`, { method: "POST" }); + expect(reset.status).toBe(200); + await reset.arrayBuffer(); + + const after = await fetch(`${mock.url}/api/v1/videos/${id}`); + expect(after.status).toBe(404); + expect((await after.json()).error.code).toBe(404); + }); + + // The deprecated alias must keep clearing job state identically — that + // back-compat is the reason it still exists. + test("POST /__aimock/reset/fixtures (deprecated alias) clears job state too", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "reset alias", endpoint: "video" }, + response: { video: { id: "vid_ral", status: "completed" } }, + }); + await mock.start(); + const id = await submitJob("reset alias"); + + const before = await fetch(`${mock.url}/api/v1/videos/${id}`); + expect(before.status).toBe(200); + await before.arrayBuffer(); + const reset = await fetch(`${mock.url}/__aimock/reset/fixtures`, { method: "POST" }); expect(reset.status).toBe(200); + expect(reset.headers.get("deprecation")).toBe("true"); await reset.arrayBuffer(); const after = await fetch(`${mock.url}/api/v1/videos/${id}`); @@ -3143,7 +3168,7 @@ describe("OpenRouter video submit — fixtures reset during a slow ResponseFacto }); // The factory is mid-await — reset the world underneath it. await new Promise((r) => setTimeout(r, 150)); - const reset = await fetch(`${mock.url}/__aimock/reset/fixtures`, { method: "POST" }); + const reset = await fetch(`${mock.url}/__aimock/reset`, { method: "POST" }); expect(reset.status).toBe(200); await reset.arrayBuffer(); diff --git a/src/llmock.ts b/src/llmock.ts index 00e49841..7fe91999 100644 --- a/src/llmock.ts +++ b/src/llmock.ts @@ -434,6 +434,19 @@ export class LLMock { * also cleared here. Those are registered through this class only — the * control API has no route that creates them, so the HTTP reset can neither * reach nor observe them. + * + * NOT ALL OF THIS IS PER-INSTANCE. `performFullReset` clears module-global + * state as well: the Gemini interaction and event-id counters + * (`resetInteractionCounter` / `resetEventIdCounter` in + * `./gemini-interactions.js`) and the fal.ai job/queue maps (`falJobs`, + * `falQueueStates`). With two `LLMock` instances live in one process, + * `a.reset()` rewinds the Gemini id sequence that `b` is mid-way through — + * `b` then re-emits `aimock-int-0` / `evt_1`, ids it has already handed + * out — and drops `b`'s in-flight fal jobs. Give each instance its own + * process (or its own vitest worker) if that matters. + * + * The global stores are cleared even before `start()`, when there is no + * server instance to reset. */ reset(): this { this.searchFixtures.length = 0; From e5753830104b1c9e5a192bd1e642f8c3160babef Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 20:15:07 -0700 Subject: [PATCH 6/7] test(pytest): guard the client's route choice, and cover the log and gauge signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python client's switch to the canonical route was the only behavior change in the diff with no guard: both routes full-reset, so test_reset_clears_fixtures passes either way, and reset_fixtures() had no test at all. Both now spy on the control call and assert the canonical route's deprecation-free response, with the alias asserted alongside so the discriminator is anchored rather than vacuous. Also covers the two remaining deprecation signals — the log warning, in both directions, and the fixtures-loaded gauge that reset() now re-zeroes. Drops a weaker duplicate of the alias test, scopes the seeder handle to try/finally, orders the changelog Changed-before-Deprecated, and states precisely that the replay 404 and strict 503 both carry code: no_fixture_match. --- CHANGELOG.md | 10 +-- docs/control-api/index.html | 5 +- packages/aimock-pytest/tests/test_basic.py | 73 ++++++++++++++++++++++ src/__tests__/control-api.test.ts | 37 ++++++----- src/__tests__/llmock.test.ts | 46 ++++++++++---- 5 files changed, 137 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a46743e..8016a060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,6 @@ ## [Unreleased] -### Deprecated - -- `POST /__aimock/reset/fixtures` — now a deprecated alias for `POST /__aimock/reset`. The name promised a fixtures-only reset while it always performed the full reset, so `/reset` is the honest route and the deprecation moves onto the alias: it still performs the same full reset but emits a `Deprecation: true` response header, `deprecated` / `deprecation` fields in the body, and a log warning. Use `POST /__aimock/reset` for a full reset, `POST /__aimock/reset/journal` for a journal-only one, or `DELETE /__aimock/fixtures` to clear fixtures and nothing else. - - The alias's **reset semantics are unchanged** — it clears exactly what it always did, so existing callers keep working. Its **response body is additively extended**: it now carries `deprecated` and `deprecation` alongside `reset`, plus the `Deprecation: true` header. A caller asserting strict equality on the old `{ "reset": true }` body will need to relax that assertion; a caller reading `body.reset` is unaffected. - ### Changed - `POST /__aimock/reset` is now the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/journal` is unaffected. @@ -15,6 +10,11 @@ - `aimock-pytest`: `AIMockServer.reset()` and `.reset_fixtures()` now call `POST /__aimock/reset`. Both already performed a full reset, so the observable behavior is unchanged. - **This does not yet avoid the deprecation warning.** `_version.py` pins `AIMOCK_VERSION = "1.38.0"`, and on that published server `/reset` is still the deprecated alias — so the client trips a deprecation on every reset until the pin moves. **Release follow-up (required):** bump `AIMOCK_VERSION` to the first npm release containing this change before publishing the next `aimock-pytest`. Until then the client is deprecation-clean only against a server built from this branch. +### Deprecated + +- `POST /__aimock/reset/fixtures` — now a deprecated alias for `POST /__aimock/reset`. The name promised a fixtures-only reset while it always performed the full reset, so `/reset` is the honest route and the deprecation moves onto the alias: it still performs the same full reset but emits a `Deprecation: true` response header, `deprecated` / `deprecation` fields in the body, and a log warning. Use `POST /__aimock/reset` for a full reset, `POST /__aimock/reset/journal` for a journal-only one, or `DELETE /__aimock/fixtures` to clear fixtures and nothing else. + - The alias's **reset semantics are unchanged** — it clears exactly what it always did, so existing callers keep working. Its **response body is additively extended**: it now carries `deprecated` and `deprecation` alongside `reset`, plus the `Deprecation: true` header. A caller asserting strict equality on the old `{ "reset": true }` body will need to relax that assertion; a caller reading `body.reset` is unaffected. + ## [1.38.0] - 2026-08-03 ### Added diff --git a/docs/control-api/index.html b/docs/control-api/index.html index 855d0cd1..337237c1 100644 --- a/docs/control-api/index.html +++ b/docs/control-api/index.html @@ -144,8 +144,9 @@

Reset Routes

> Pick the narrower routePOST /__aimock/reset wipes the loaded fixtures along with everything else, - and what happens to the next request then depends on the mode. In replay mode it returns - no_fixture_match; in strict mode it returns 503; but + and what happens to the next request then depends on the mode. In replay mode it fails + with 404, and in strict mode with 503 — both carry + code: "no_fixture_match". But in record mode, with a provider key configured, an unmatched request is proxied to the real provider { expect(instance.journal.getFixtureMatchCount(fixtures[0])).toBe(countBefore); }); - it("POST /__aimock/reset/fixtures is a deprecated alias that still performs a full reset", async () => { - const fixtures: Fixture[] = [ - { match: { userMessage: "hello" }, response: { content: "Hi" } }, - ]; - instance = await createServer(fixtures); - - const res = await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST"); - expect(res.status).toBe(200); - const body = JSON.parse(res.body); - expect(body).toMatchObject({ reset: true, deprecated: true }); - expect(typeof body.deprecation).toBe("string"); - expect(fixtures.length).toBe(0); - }); + // The alias's deprecation signal and its full-reset behaviour are covered + // together, and more strictly, by "full-reset deprecation direction" below. }); describe("full-reset deprecation direction", () => { @@ -335,6 +324,26 @@ describe("/__aimock control API", () => { expect(fixtures.length).toBe(0); expect(instance.journal.size).toBe(0); }); + + // The log warning is the third documented deprecation signal, alongside + // the header and the body fields. The suite defaults to logLevel silent, + // so the level has to be raised for the warning to reach console.warn. + it("logs a deprecation warning for the alias and stays silent for the canonical route", async () => { + instance = await createServer([], { logLevel: "warn" }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await httpRequest(`${instance.url}/__aimock/reset`, "POST"); + expect(warn).not.toHaveBeenCalled(); + + await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0].join(" ")).toContain( + "POST /__aimock/reset/fixtures is deprecated; use /__aimock/reset or /__aimock/reset/journal", + ); + } finally { + warn.mockRestore(); + } + }); }); describe("DELETE /v1/_requests", () => { diff --git a/src/__tests__/llmock.test.ts b/src/__tests__/llmock.test.ts index 98cd3452..7ec9ba0d 100644 --- a/src/__tests__/llmock.test.ts +++ b/src/__tests__/llmock.test.ts @@ -1326,19 +1326,25 @@ describe("LLMock", () => { seeder.onFalAudio("drum loop", { audio: "SGVsbG8=", format: "mp3" }); seeder.onMessage("hello", { content: "Hi there!" }); await seeder.start(); - const envelope = (await ( - await fetch(`${seeder.url}/fal/queue/submit/fal-ai/stable-audio`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: "drum loop" }), - }) - ).json()) as { request_id: string }; - await postTo(seeder.url, "/v1beta/interactions", { - model: "gemini-2.5-flash", - input: "hello", - stream: false, - }); - await seeder.stop(); + // The file-level afterEach only stops `mock`, so this handle is ours to + // close on every path. + let envelope: { request_id: string }; + try { + envelope = (await ( + await fetch(`${seeder.url}/fal/queue/submit/fal-ai/stable-audio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "drum loop" }), + }) + ).json()) as { request_id: string }; + await postTo(seeder.url, "/v1beta/interactions", { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }); + } finally { + await seeder.stop(); + } // A brand-new, NEVER-STARTED instance resets the process-global state. const unstarted = new LLMock(); @@ -1365,6 +1371,20 @@ describe("LLMock", () => { ) as { id: string }; expect(interaction.id).toBe("aimock-int-0"); }); + + it("re-zeroes the aimock_fixtures_loaded gauge", async () => { + mock = new LLMock({ metrics: true }); + mock.onMessage("a", { content: "1" }); + mock.onMessage("b", { content: "2" }); + await mock.start(); + + const scrape = async () => (await fetch(`${mock!.url}/metrics`)).text(); + expect(await scrape()).toContain("aimock_fixtures_loaded{} 2"); + + mock.reset(); + + expect(await scrape()).toContain("aimock_fixtures_loaded{} 0"); + }); }); describe("baseUrl getter", () => { From 1101d2bb97b23be9880339252681827370fa8dae Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 5 Aug 2026 20:24:40 -0700 Subject: [PATCH 7/7] test(pytest): assert the discriminating half of the alias deprecation message "POST /__aimock/reset" is a prefix of the alias's own name, so the anchor assertion held even for a message pointing back at /reset/fixtures. It now keys on "use POST /__aimock/reset (full reset)", which a self-referential message does not contain. --- packages/aimock-pytest/tests/test_basic.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/aimock-pytest/tests/test_basic.py b/packages/aimock-pytest/tests/test_basic.py index eef5aee9..cacf05f2 100644 --- a/packages/aimock-pytest/tests/test_basic.py +++ b/packages/aimock-pytest/tests/test_basic.py @@ -232,7 +232,10 @@ def test_reset_targets_the_canonical_route_not_the_deprecated_alias(aimock, monk assert alias.status_code == 200 alias_body = alias.json() assert alias_body["deprecated"] is True - assert "POST /__aimock/reset" in alias_body["deprecation"] + # The discriminating substring: a self-referential message would read + # "use POST /__aimock/reset/fixtures (full reset)". A bare + # "POST /__aimock/reset" check would be satisfied by that too. + assert "use POST /__aimock/reset (full reset)" in alias_body["deprecation"] assert alias.headers["Deprecation"] == "true"