diff --git a/CHANGELOG.md b/CHANGELOG.md index 324d133c..8016a060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### 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. +- **`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. + +### 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 9a5db20b..337237c1 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,14 @@

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). 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. A third, POST /__aimock/reset/fixtures, is a + deprecated alias for the full reset.

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, + 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 + — 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.
-

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 +190,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 +212,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 +270,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 diff --git a/packages/aimock-pytest/tests/test_basic.py b/packages/aimock-pytest/tests/test_basic.py index fb43b4a8..cacf05f2 100644 --- a/packages/aimock-pytest/tests/test_basic.py +++ b/packages/aimock-pytest/tests/test_basic.py @@ -184,6 +184,82 @@ def test_reset_clears_fixtures(aimock): assert r.status_code == 404 +def _capture_control_posts(monkeypatch): + """Spy on requests.post, recording every /__aimock/ control call. + + Returns the list the spy appends ``(url, response)`` to. The real request + still goes out, so the recorded response is the SERVER's, not a stub. + """ + captured = [] + real_post = requests.post + + def spy(url, *args, **kwargs): + response = real_post(url, *args, **kwargs) + if "/__aimock/" in url: + captured.append((url, response)) + return response + + monkeypatch.setattr(requests, "post", spy) + return captured + + +def test_reset_targets_the_canonical_route_not_the_deprecated_alias(aimock, monkeypatch): + """reset() must POST /__aimock/reset, which carries no deprecation signal. + + Both routes perform the same full reset, so a functional assertion cannot + tell them apart. The deprecation signal can: the alias returns + ``deprecated``/``deprecation`` in the body and a ``Deprecation`` header, + the canonical route returns neither. + """ + captured = _capture_control_posts(monkeypatch) + aimock.reset() + + assert len(captured) == 1 + url, response = captured[0] + assert url.endswith("/__aimock/reset") + assert response.status_code == 200 + + body = response.json() + assert body == {"reset": True} + assert "deprecated" not in body + assert "deprecation" not in body + assert "Deprecation" not in response.headers + + # Anchor the discriminator: the alias DOES signal deprecation, so the + # assertions above genuinely distinguish the two routes rather than + # passing for both. + alias = requests.post(f"{aimock.base_url}/__aimock/reset/fixtures", timeout=5) + assert alias.status_code == 200 + alias_body = alias.json() + assert alias_body["deprecated"] is True + # 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" + + +def test_reset_fixtures_is_a_deprecation_free_alias_for_reset(aimock, monkeypatch): + """reset_fixtures() delegates to reset(), so it too uses /__aimock/reset.""" + aimock.on_message("test", {"content": "response"}) + + captured = _capture_control_posts(monkeypatch) + aimock.reset_fixtures() + + assert len(captured) == 1 + url, response = captured[0] + assert url.endswith("/__aimock/reset") + assert response.json() == {"reset": True} + assert "Deprecation" not in response.headers + + # ...and it still performs the full reset its name promises. + r = requests.post( + f"{aimock.base_url}/v1/chat/completions", + json={"model": "gpt-4", "messages": [{"role": "user", "content": "test"}]}, + ) + assert r.status_code == 404 + + def test_reset_journal_preserves_fixtures(aimock): """reset_journal() clears the journal but leaves fixtures intact.""" aimock.on_message("hello", {"content": "Hi there!"}) diff --git a/src/__tests__/control-api.test.ts b/src/__tests__/control-api.test.ts index 7a4c1969..248922b9 100644 --- a/src/__tests__/control-api.test.ts +++ b/src/__tests__/control-api.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import * as http from "node:http"; import type { Fixture, ChatCompletionRequest } from "../types.js"; import { createServer, type ServerInstance } from "../server.js"; @@ -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(), }), ); @@ -194,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); }); }); @@ -219,7 +224,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); }); @@ -267,18 +272,77 @@ 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 () => { + // 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", () => { + 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); - const body = JSON.parse(res.body); - expect(body).toMatchObject({ reset: true, deprecated: true }); - expect(typeof body.deprecation).toBe("string"); + 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); + // 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); + 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(); + } }); }); diff --git a/src/__tests__/llmock.test.ts b/src/__tests__/llmock.test.ts index b797c031..7ec9ba0d 100644 --- a/src/__tests__/llmock.test.ts +++ b/src/__tests__/llmock.test.ts @@ -1041,6 +1041,350 @@ 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"); + }); + + 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(); + + // 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(); + // 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(); + 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"); + }); + + 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", () => { 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 fe6b840d..7fe91999 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,33 @@ 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. + * + * 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.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..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 @@ -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();