@copilotkit/aimock 1.42.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +56 -0
- package/README.md +102 -16
- package/dist/agui-mock.cjs +1 -1
- package/dist/agui-mock.cjs.map +1 -1
- package/dist/agui-mock.d.cts.map +1 -1
- package/dist/agui-mock.d.ts.map +1 -1
- package/dist/agui-mock.js +1 -1
- package/dist/agui-mock.js.map +1 -1
- package/dist/agui-recorder.cjs +33 -11
- package/dist/agui-recorder.cjs.map +1 -1
- package/dist/agui-recorder.d.cts +10 -1
- package/dist/agui-recorder.d.cts.map +1 -1
- package/dist/agui-recorder.d.ts +10 -1
- package/dist/agui-recorder.d.ts.map +1 -1
- package/dist/agui-recorder.js +33 -11
- package/dist/agui-recorder.js.map +1 -1
- package/dist/agui-types.d.cts +1 -0
- package/dist/agui-types.d.cts.map +1 -1
- package/dist/agui-types.d.ts +1 -0
- package/dist/agui-types.d.ts.map +1 -1
- package/dist/aimock-cli.cjs +38 -10
- package/dist/aimock-cli.cjs.map +1 -1
- package/dist/aimock-cli.d.cts +5 -0
- package/dist/aimock-cli.d.cts.map +1 -1
- package/dist/aimock-cli.d.ts +5 -0
- package/dist/aimock-cli.d.ts.map +1 -1
- package/dist/aimock-cli.js +38 -10
- package/dist/aimock-cli.js.map +1 -1
- package/dist/batches.cjs +458 -0
- package/dist/batches.cjs.map +1 -0
- package/dist/batches.d.cts +57 -0
- package/dist/batches.d.cts.map +1 -0
- package/dist/batches.d.ts +57 -0
- package/dist/batches.d.ts.map +1 -0
- package/dist/batches.js +453 -0
- package/dist/batches.js.map +1 -0
- package/dist/bedrock-converse.cjs +12 -8
- package/dist/bedrock-converse.cjs.map +1 -1
- package/dist/bedrock-converse.d.cts.map +1 -1
- package/dist/bedrock-converse.d.ts.map +1 -1
- package/dist/bedrock-converse.js +14 -10
- package/dist/bedrock-converse.js.map +1 -1
- package/dist/bedrock.cjs +10 -8
- package/dist/bedrock.cjs.map +1 -1
- package/dist/bedrock.d.cts.map +1 -1
- package/dist/bedrock.d.ts.map +1 -1
- package/dist/bedrock.js +12 -10
- package/dist/bedrock.js.map +1 -1
- package/dist/byteplus-video.cjs +3 -3
- package/dist/byteplus-video.cjs.map +1 -1
- package/dist/byteplus-video.js +4 -4
- package/dist/byteplus-video.js.map +1 -1
- package/dist/chaos.cjs +622 -72
- package/dist/chaos.cjs.map +1 -1
- package/dist/chaos.d.cts +164 -7
- package/dist/chaos.d.cts.map +1 -1
- package/dist/chaos.d.ts +164 -7
- package/dist/chaos.d.ts.map +1 -1
- package/dist/chaos.js +613 -74
- package/dist/chaos.js.map +1 -1
- package/dist/cli.cjs +42 -23
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +42 -23
- package/dist/cli.js.map +1 -1
- package/dist/cohere.cjs +10 -8
- package/dist/cohere.cjs.map +1 -1
- package/dist/cohere.d.cts.map +1 -1
- package/dist/cohere.d.ts.map +1 -1
- package/dist/cohere.js +12 -10
- package/dist/cohere.js.map +1 -1
- package/dist/config-loader.d.cts.map +1 -1
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/convert.cjs +1 -1
- package/dist/convert.cjs.map +1 -1
- package/dist/convert.js +1 -1
- package/dist/convert.js.map +1 -1
- package/dist/elevenlabs-audio.cjs +3 -3
- package/dist/elevenlabs-audio.cjs.map +1 -1
- package/dist/elevenlabs-audio.js +4 -4
- package/dist/elevenlabs-audio.js.map +1 -1
- package/dist/elevenlabs-voice.cjs +1053 -0
- package/dist/elevenlabs-voice.cjs.map +1 -0
- package/dist/elevenlabs-voice.d.cts +72 -0
- package/dist/elevenlabs-voice.d.cts.map +1 -0
- package/dist/elevenlabs-voice.d.ts +72 -0
- package/dist/elevenlabs-voice.d.ts.map +1 -0
- package/dist/elevenlabs-voice.js +1048 -0
- package/dist/elevenlabs-voice.js.map +1 -0
- package/dist/embeddings.cjs +8 -4
- package/dist/embeddings.cjs.map +1 -1
- package/dist/embeddings.d.cts.map +1 -1
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/embeddings.js +9 -5
- package/dist/embeddings.js.map +1 -1
- package/dist/fal-audio.cjs +3 -3
- package/dist/fal-audio.cjs.map +1 -1
- package/dist/fal-audio.js +4 -4
- package/dist/fal-audio.js.map +1 -1
- package/dist/fal.cjs +28 -4
- package/dist/fal.cjs.map +1 -1
- package/dist/fal.d.cts +16 -1
- package/dist/fal.d.cts.map +1 -1
- package/dist/fal.d.ts +16 -1
- package/dist/fal.d.ts.map +1 -1
- package/dist/fal.js +28 -5
- package/dist/fal.js.map +1 -1
- package/dist/files.cjs +1071 -0
- package/dist/files.cjs.map +1 -0
- package/dist/files.d.cts +68 -0
- package/dist/files.d.cts.map +1 -0
- package/dist/files.d.ts +68 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +1059 -0
- package/dist/files.js.map +1 -0
- package/dist/fine-tuning.cjs +1051 -0
- package/dist/fine-tuning.cjs.map +1 -0
- package/dist/fine-tuning.d.cts +96 -0
- package/dist/fine-tuning.d.cts.map +1 -0
- package/dist/fine-tuning.d.ts +96 -0
- package/dist/fine-tuning.d.ts.map +1 -0
- package/dist/fine-tuning.js +1044 -0
- package/dist/fine-tuning.js.map +1 -0
- package/dist/fixture-loader.cjs +0 -0
- package/dist/fixture-loader.cjs.map +1 -1
- package/dist/fixture-loader.d.cts +37 -5
- package/dist/fixture-loader.d.cts.map +1 -1
- package/dist/fixture-loader.d.ts +37 -5
- package/dist/fixture-loader.d.ts.map +1 -1
- package/dist/fixture-loader.js +0 -0
- package/dist/fixture-loader.js.map +1 -1
- package/dist/gemini-embeddings.cjs +2 -2
- package/dist/gemini-embeddings.cjs.map +1 -1
- package/dist/gemini-embeddings.js +3 -3
- package/dist/gemini-embeddings.js.map +1 -1
- package/dist/gemini-interactions.cjs +13 -7
- package/dist/gemini-interactions.cjs.map +1 -1
- package/dist/gemini-interactions.d.cts.map +1 -1
- package/dist/gemini-interactions.d.ts.map +1 -1
- package/dist/gemini-interactions.js +15 -9
- package/dist/gemini-interactions.js.map +1 -1
- package/dist/gemini.cjs +11 -7
- package/dist/gemini.cjs.map +1 -1
- package/dist/gemini.d.cts.map +1 -1
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +13 -9
- package/dist/gemini.js.map +1 -1
- package/dist/grok-video.cjs +14 -14
- package/dist/grok-video.cjs.map +1 -1
- package/dist/grok-video.js +15 -15
- package/dist/grok-video.js.map +1 -1
- package/dist/helpers.cjs +229 -7
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +38 -2
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts +38 -2
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +221 -8
- package/dist/helpers.js.map +1 -1
- package/dist/images.cjs +54 -123
- package/dist/images.cjs.map +1 -1
- package/dist/images.d.cts.map +1 -1
- package/dist/images.d.ts.map +1 -1
- package/dist/images.js +55 -124
- package/dist/images.js.map +1 -1
- package/dist/index.cjs +41 -8
- package/dist/index.d.cts +12 -7
- package/dist/index.d.ts +12 -7
- package/dist/index.js +14 -10
- package/dist/jest.cjs +14 -7
- package/dist/jest.cjs.map +1 -1
- package/dist/jest.d.cts +3 -2
- package/dist/jest.d.cts.map +1 -1
- package/dist/jest.d.ts +3 -2
- package/dist/jest.d.ts.map +1 -1
- package/dist/jest.js +14 -7
- package/dist/jest.js.map +1 -1
- package/dist/journal.cjs +41 -4
- package/dist/journal.cjs.map +1 -1
- package/dist/journal.d.cts +44 -3
- package/dist/journal.d.cts.map +1 -1
- package/dist/journal.d.ts +44 -3
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +41 -5
- package/dist/journal.js.map +1 -1
- package/dist/live-fixture.cjs +1192 -0
- package/dist/live-fixture.cjs.map +1 -0
- package/dist/live-fixture.js +1187 -0
- package/dist/live-fixture.js.map +1 -0
- package/dist/live-recorder.cjs +256 -0
- package/dist/live-recorder.cjs.map +1 -0
- package/dist/live-recorder.js +254 -0
- package/dist/live-recorder.js.map +1 -0
- package/dist/live-replay.cjs +286 -0
- package/dist/live-replay.cjs.map +1 -0
- package/dist/live-replay.js +285 -0
- package/dist/live-replay.js.map +1 -0
- package/dist/live-sanitize.cjs +196 -0
- package/dist/live-sanitize.cjs.map +1 -0
- package/dist/live-sanitize.js +196 -0
- package/dist/live-sanitize.js.map +1 -0
- package/dist/live-types.d.cts +71 -0
- package/dist/live-types.d.cts.map +1 -0
- package/dist/live-types.d.ts +71 -0
- package/dist/live-types.d.ts.map +1 -0
- package/dist/llmock.cjs +52 -41
- package/dist/llmock.cjs.map +1 -1
- package/dist/llmock.d.cts +16 -5
- package/dist/llmock.d.cts.map +1 -1
- package/dist/llmock.d.ts +16 -5
- package/dist/llmock.d.ts.map +1 -1
- package/dist/llmock.js +53 -42
- package/dist/llmock.js.map +1 -1
- package/dist/messages.cjs +11 -7
- package/dist/messages.cjs.map +1 -1
- package/dist/messages.d.cts.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +13 -9
- package/dist/messages.js.map +1 -1
- package/dist/metrics.cjs +258 -5
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.d.cts +3 -1
- package/dist/metrics.d.cts.map +1 -1
- package/dist/metrics.d.ts +3 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +255 -6
- package/dist/metrics.js.map +1 -1
- package/dist/moderation.cjs +10 -1
- package/dist/moderation.cjs.map +1 -1
- package/dist/moderation.d.cts.map +1 -1
- package/dist/moderation.d.ts.map +1 -1
- package/dist/moderation.js +10 -1
- package/dist/moderation.js.map +1 -1
- package/dist/ollama.cjs +5 -5
- package/dist/ollama.cjs.map +1 -1
- package/dist/ollama.js +6 -6
- package/dist/ollama.js.map +1 -1
- package/dist/openrouter-video.cjs +5 -5
- package/dist/openrouter-video.cjs.map +1 -1
- package/dist/openrouter-video.js +6 -6
- package/dist/openrouter-video.js.map +1 -1
- package/dist/recorder.cjs +45 -9
- package/dist/recorder.cjs.map +1 -1
- package/dist/recorder.d.cts.map +1 -1
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +43 -11
- package/dist/recorder.js.map +1 -1
- package/dist/responses.cjs +4 -3
- package/dist/responses.cjs.map +1 -1
- package/dist/responses.js +5 -5
- package/dist/responses.js.map +1 -1
- package/dist/router.cjs +20 -33
- package/dist/router.cjs.map +1 -1
- package/dist/router.d.cts +4 -1
- package/dist/router.d.cts.map +1 -1
- package/dist/router.d.ts +4 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +20 -34
- package/dist/router.js.map +1 -1
- package/dist/server.cjs +701 -625
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +1 -0
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +704 -628
- package/dist/server.js.map +1 -1
- package/dist/speech.cjs +2 -2
- package/dist/speech.cjs.map +1 -1
- package/dist/speech.js +3 -3
- package/dist/speech.js.map +1 -1
- package/dist/transcription.cjs +4 -4
- package/dist/transcription.cjs.map +1 -1
- package/dist/transcription.js +5 -5
- package/dist/transcription.js.map +1 -1
- package/dist/types.d.cts +119 -16
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +119 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cli.cjs +925 -0
- package/dist/validate-cli.cjs.map +1 -0
- package/dist/validate-cli.js +924 -0
- package/dist/validate-cli.js.map +1 -0
- package/dist/vector-types.d.cts.map +1 -1
- package/dist/vector-types.d.ts.map +1 -1
- package/dist/veo-video.cjs +13 -13
- package/dist/veo-video.cjs.map +1 -1
- package/dist/veo-video.js +14 -14
- package/dist/veo-video.js.map +1 -1
- package/dist/video.cjs +4 -4
- package/dist/video.cjs.map +1 -1
- package/dist/video.d.cts +1 -1
- package/dist/video.d.cts.map +1 -1
- package/dist/video.d.ts +1 -1
- package/dist/video.d.ts.map +1 -1
- package/dist/video.js +5 -5
- package/dist/video.js.map +1 -1
- package/dist/vitest.cjs +13 -6
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +2 -1
- package/dist/vitest.d.cts.map +1 -1
- package/dist/vitest.d.ts +2 -1
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +14 -7
- package/dist/vitest.js.map +1 -1
- package/dist/watcher.cjs +2 -1
- package/dist/watcher.cjs.map +1 -1
- package/dist/watcher.js +2 -1
- package/dist/watcher.js.map +1 -1
- package/dist/ws-framing.cjs +150 -35
- package/dist/ws-framing.cjs.map +1 -1
- package/dist/ws-framing.d.cts +18 -3
- package/dist/ws-framing.d.cts.map +1 -1
- package/dist/ws-framing.d.ts +18 -3
- package/dist/ws-framing.d.ts.map +1 -1
- package/dist/ws-framing.js +151 -36
- package/dist/ws-framing.js.map +1 -1
- package/dist/ws-gemini-live.cjs +2 -2
- package/dist/ws-gemini-live.d.ts +2 -2
- package/dist/ws-gemini-live.js +2 -2
- package/dist/ws-live.cjs +401 -0
- package/dist/ws-live.cjs.map +1 -0
- package/dist/ws-live.js +400 -0
- package/dist/ws-live.js.map +1 -0
- package/dist/ws-realtime.cjs +46 -2
- package/dist/ws-realtime.cjs.map +1 -1
- package/dist/ws-realtime.d.cts.map +1 -1
- package/dist/ws-realtime.d.ts +2 -2
- package/dist/ws-realtime.d.ts.map +1 -1
- package/dist/ws-realtime.js +46 -2
- package/dist/ws-realtime.js.map +1 -1
- package/dist/ws-responses.cjs +2 -2
- package/dist/ws-responses.js +2 -2
- package/dist/ws-upstream.cjs +118 -0
- package/dist/ws-upstream.cjs.map +1 -0
- package/dist/ws-upstream.js +115 -0
- package/dist/ws-upstream.js.map +1 -0
- package/fixtures/openai-live-client.json +390 -0
- package/fixtures/openai-live-managed.json +709 -0
- package/package.json +1 -1
package/dist/chaos.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { DEFAULT_TEST_ID } from "./constants.js";
|
|
2
|
+
import { describeMatch, resolveTestId } from "./helpers.js";
|
|
3
|
+
import { delay, writeErrorResponse } from "./sse-writer.js";
|
|
3
4
|
|
|
4
5
|
//#region src/chaos.ts
|
|
5
6
|
/**
|
|
6
|
-
* Narrow the server-defaults argument.
|
|
7
|
-
* `ChaosConfig`
|
|
7
|
+
* Narrow the server-defaults argument. The discrimination is STRUCTURAL: a
|
|
8
|
+
* `ChaosConfig` only ever carries the fault fields (`dropRate`,
|
|
9
|
+
* `malformedRate`, `rateLimitRate`, `disconnectRate`, `latencyMs`), so the
|
|
10
|
+
* presence of a `base` / `byTestId` key means a `ChaosScope`.
|
|
11
|
+
*
|
|
12
|
+
* Both members are optional, so `{}` satisfies BOTH types and is reported here
|
|
13
|
+
* as a config, not a scope. That is not a bug to route around: an empty scope
|
|
14
|
+
* and an empty config resolve to exactly the same thing (no chaos, nothing to
|
|
15
|
+
* select), so no caller can observe which branch ran. The server's own defaults
|
|
16
|
+
* getter (`src/server.ts`) always sets `byTestId`, so a real server's value is
|
|
17
|
+
* never the ambiguous one. A THIRD variant, or a scope whose emptiness became
|
|
18
|
+
* observable, would need a tagged discriminant instead of this shape test.
|
|
8
19
|
*/
|
|
9
20
|
function isChaosScope(defaults) {
|
|
10
21
|
return "base" in defaults || "byTestId" in defaults;
|
|
@@ -15,75 +26,517 @@ function isChaosScope(defaults) {
|
|
|
15
26
|
* resolved by `resolveTestId` — the same helper `getTestId` and the control API
|
|
16
27
|
* use — so a harness that tags by `?testId=` lands in the same scope its chaos
|
|
17
28
|
* override was stored under, and the two sides cannot disagree.
|
|
29
|
+
*
|
|
30
|
+
* SELECTION, NOT MERGE. A scoped override REPLACES the baseline wholesale; it
|
|
31
|
+
* is not layered field-wise over it. A server started with `--chaos-latency
|
|
32
|
+
* 500` whose test installs `{ dropRate: 1 }` sees drops with NO latency on that
|
|
33
|
+
* test's traffic — restate `latencyMs` in the override to keep it. See
|
|
34
|
+
* `ChaosScope` in types.ts for why replacement is the contract (it is what
|
|
35
|
+
* separates `POST /__aimock/chaos {}` from `DELETE`), and note that the two
|
|
36
|
+
* links above this one — fixture and headers, applied below — DO merge
|
|
37
|
+
* field-wise. `POST`/`GET /__aimock/chaos` echo the config actually in effect
|
|
38
|
+
* for the scope, and the control API warns when an install drops a field that
|
|
39
|
+
* was in effect, so nothing is lost silently.
|
|
40
|
+
*
|
|
41
|
+
* PRESENCE of the entry is what selects, not its truthiness: the lookup asks
|
|
42
|
+
* `has`, not `get`. A map built with the key mapped to `undefined` (or to an
|
|
43
|
+
* empty object) is an override that was explicitly installed and says "no
|
|
44
|
+
* chaos for this test" — the `POST /__aimock/chaos {}` case — so it must win
|
|
45
|
+
* over `base` exactly like a populated one. Testing `get(...)` truthiness would
|
|
46
|
+
* quietly demote that one shape back to the baseline, which is the merge
|
|
47
|
+
* behaviour this contract exists to rule out.
|
|
18
48
|
*/
|
|
19
49
|
function resolveScopedDefaults(serverDefaults, rawHeaders, url) {
|
|
20
50
|
if (!serverDefaults) return void 0;
|
|
21
51
|
if (!isChaosScope(serverDefaults)) return serverDefaults;
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
52
|
+
const testId = resolveTestId(rawHeaders ?? {}, url);
|
|
53
|
+
if (serverDefaults.byTestId?.has(testId)) return serverDefaults.byTestId.get(testId) ?? {};
|
|
24
54
|
return serverDefaults.base;
|
|
25
55
|
}
|
|
26
56
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
57
|
+
* Every chaos field, with its accepted upper bound and its header spelling.
|
|
58
|
+
* ONE table: every source — request header, fixture chaos, server default, the
|
|
59
|
+
* `POST /__aimock/chaos` control API and the `--chaos-*` CLI flags — resolves a
|
|
60
|
+
* field through this entry via {@link parseChaosField}, so no source can drift
|
|
61
|
+
* onto its own bound or its own idea of which fields take a whole number.
|
|
62
|
+
*
|
|
63
|
+
* Exported for the two surfaces that live in other modules (`src/server.ts`'s
|
|
64
|
+
* control API, `src/cli.ts`'s flag parsing); in-module callers go through
|
|
65
|
+
* {@link parseChaosField} too.
|
|
30
66
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
const CHAOS_FIELDS = {
|
|
68
|
+
dropRate: {
|
|
69
|
+
header: "x-aimock-chaos-drop",
|
|
70
|
+
max: 1,
|
|
71
|
+
integer: false
|
|
72
|
+
},
|
|
73
|
+
malformedRate: {
|
|
74
|
+
header: "x-aimock-chaos-malformed",
|
|
75
|
+
max: 1,
|
|
76
|
+
integer: false
|
|
77
|
+
},
|
|
78
|
+
disconnectRate: {
|
|
79
|
+
header: "x-aimock-chaos-disconnect",
|
|
80
|
+
max: 1,
|
|
81
|
+
integer: false
|
|
82
|
+
},
|
|
83
|
+
rateLimitRate: {
|
|
84
|
+
header: "x-aimock-chaos-ratelimit",
|
|
85
|
+
max: 1,
|
|
86
|
+
integer: false
|
|
87
|
+
},
|
|
88
|
+
latencyMs: {
|
|
89
|
+
header: "x-aimock-chaos-latency",
|
|
90
|
+
max: 3e4,
|
|
91
|
+
integer: true
|
|
37
92
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
93
|
+
};
|
|
94
|
+
const CHAOS_FIELD_NAMES = Object.keys(CHAOS_FIELDS);
|
|
95
|
+
/**
|
|
96
|
+
* A plain decimal RATE: digits with an optional fractional part, and nothing
|
|
97
|
+
* else. Deliberately NOT what `Number()` accepts — `Number` is a full-string
|
|
98
|
+
* parse (unlike `parseFloat`, which would take the `0.5` out of `"0.5abc"`),
|
|
99
|
+
* but it is also a JS *literal* parse, so it happily reads `"0x1e"` as 30,
|
|
100
|
+
* `"0b1"` as 1, `"1e3"` as 1000 and `"Infinity"` as Infinity. None of those is
|
|
101
|
+
* a rate a caller meant to write, and `0x1` silently becoming `dropRate: 1`
|
|
102
|
+
* turns a typo into a 100 % outage. A leading `+`/`-` sign, `_` separators and
|
|
103
|
+
* exponents are rejected for the same reason.
|
|
104
|
+
*/
|
|
105
|
+
const DECIMAL_RATE = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
106
|
+
/** A non-negative decimal INTEGER — the only shape a millisecond count takes. */
|
|
107
|
+
const DECIMAL_INTEGER = /^\d+$/;
|
|
108
|
+
/**
|
|
109
|
+
* The ONE way a chaos number is parsed, whatever its source.
|
|
110
|
+
*
|
|
111
|
+
* Two shapes, picked per field by the `CHAOS_FIELDS` table: a RATE is a plain
|
|
112
|
+
* decimal in `[0, max]` (max is always 1), and an INTEGER field (`latencyMs`)
|
|
113
|
+
* is a whole, non-negative count of milliseconds in `[0, max]`. `integer`
|
|
114
|
+
* defaults to false so a caller that omits it gets the rate grammar.
|
|
115
|
+
*
|
|
116
|
+
* The VALUE rules apply to every source alike: `NaN` and non-finite values, a
|
|
117
|
+
* fractional value for an integer field, negative zero, and anything outside
|
|
118
|
+
* `[0, max]` are all rejected (returned as `undefined`) whether they arrived as
|
|
119
|
+
* text or as a typed number. Nothing is ever clamped — a value the caller did
|
|
120
|
+
* not ask for is never substituted for the one they did. Negative zero is
|
|
121
|
+
* rejected rather than folded to `0` so that it matches its own text form
|
|
122
|
+
* (`"-0"`, which the sign-less grammar already refuses) and so that no `-0`
|
|
123
|
+
* escapes into a config object that later gets echoed back over JSON.
|
|
124
|
+
*
|
|
125
|
+
* The GRAMMAR above — the two regexes — is a rule about TEXT, and only a string
|
|
126
|
+
* input passes through it: trailing garbage (`"0.5abc"`), non-numeric text
|
|
127
|
+
* (`"banana"`), the empty string, a sign (`"+1"`, `"-0"`) and the JS
|
|
128
|
+
* numeric-literal forms that are not plain decimals (`"0x1e"`, `"0b1"`,
|
|
129
|
+
* `"1e3"`, `"Infinity"`) are rejected as spellings. A fixture or JSON body that
|
|
130
|
+
* writes `latencyMs: 1e3` has ALREADY been through a JSON/JS parser and hands
|
|
131
|
+
* this function the number `1000`, indistinguishable from a literal `1000` — so
|
|
132
|
+
* it is accepted, while the header `"1e3"` is not. That asymmetry is the point,
|
|
133
|
+
* not a gap: the grammar exists to stop an ambiguous *spelling* on the wire
|
|
134
|
+
* (`0x1` silently becoming `dropRate: 1` turns a typo into a 100 % outage),
|
|
135
|
+
* and a value that reached us as a number has no spelling left to police.
|
|
136
|
+
*/
|
|
137
|
+
function parseChaosNumber(raw, max, integer = false) {
|
|
138
|
+
let value;
|
|
139
|
+
if (typeof raw === "number") value = raw;
|
|
140
|
+
else if (typeof raw === "string") {
|
|
141
|
+
const text = raw.trim();
|
|
142
|
+
if (!(integer ? DECIMAL_INTEGER : DECIMAL_RATE).test(text)) return void 0;
|
|
143
|
+
value = Number(text);
|
|
144
|
+
} else return;
|
|
145
|
+
if (!Number.isFinite(value)) return void 0;
|
|
146
|
+
if (integer && !Number.isInteger(value)) return void 0;
|
|
147
|
+
if (Object.is(value, -0)) return void 0;
|
|
148
|
+
if (value < 0 || value > max) return void 0;
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse a value for a NAMED chaos field, taking its bound and its integer-ness
|
|
153
|
+
* from `CHAOS_FIELDS` instead of from the call site. Every entry point should
|
|
154
|
+
* call this rather than `parseChaosNumber` with hand-written limits — that is
|
|
155
|
+
* what makes the "ONE table" claim above true instead of aspirational.
|
|
156
|
+
*/
|
|
157
|
+
function parseChaosField(field, raw) {
|
|
158
|
+
return parseChaosNumber(raw, CHAOS_FIELDS[field].max, CHAOS_FIELDS[field].integer);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The single value a repeated header carries. Node models a repeat two ways —
|
|
162
|
+
* as a `string[]`, and (for most headers) folded into one comma-joined string —
|
|
163
|
+
* and ONE rule covers both shapes, so the two can never diverge: split on
|
|
164
|
+
* commas, trim, drop empties, and the FIRST NON-EMPTY value wins.
|
|
165
|
+
*
|
|
166
|
+
* "Non-empty" is the load-bearing word. `x-aimock-chaos-latency: ` followed by
|
|
167
|
+
* `x-aimock-chaos-latency: 500` reaches the handler as `",500"` or
|
|
168
|
+
* `["", "500"]` depending on the shape; taking element `[0]` blindly made the
|
|
169
|
+
* folded form warn "rejected latencyMs value \"\"" while the array form went
|
|
170
|
+
* silently absent — two behaviours for one wire-level request. An empty value
|
|
171
|
+
* carries no instruction, so it is skipped rather than rejected.
|
|
172
|
+
*
|
|
173
|
+
* A repeat whose values DISAGREE is a real misconfiguration (two different
|
|
174
|
+
* rates asked for on one request), so it warns once and the first wins.
|
|
175
|
+
* "Disagree" is judged on what the values MEAN, not how they are spelled:
|
|
176
|
+
* `0.5, 0.50` is one rate written twice and says nothing worth warning about,
|
|
177
|
+
* whereas a TEXT comparison called it a conflict. Values the parser rejects
|
|
178
|
+
* have no meaning to compare, so they stand for themselves — two different
|
|
179
|
+
* typos still disagree, and a typo still disagrees with a valid number.
|
|
180
|
+
*/
|
|
181
|
+
function firstHeaderValue(raw, field, logger) {
|
|
182
|
+
const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).flatMap((part) => part.split(",")).map((part) => part.trim()).filter((part) => part !== "");
|
|
183
|
+
const first = values[0];
|
|
184
|
+
if (first === void 0) return void 0;
|
|
185
|
+
const firstMeaning = headerValueMeaning(first, field);
|
|
186
|
+
if (values.some((value) => headerValueMeaning(value, field) !== firstMeaning)) logger?.warn(`[chaos] ${CHAOS_FIELDS[field].header}: repeated header with conflicting values ${JSON.stringify(values)} — using the first (${JSON.stringify(first)})`);
|
|
187
|
+
return first;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* What one spelling of a header value MEANS, as a comparable token: the parsed
|
|
191
|
+
* number when the grammar accepts it, else the raw text. Prefixed so a rejected
|
|
192
|
+
* literal `"0.5"` could never compare equal to the parsed number 0.5.
|
|
193
|
+
*/
|
|
194
|
+
function headerValueMeaning(text, field) {
|
|
195
|
+
const { max, integer } = CHAOS_FIELDS[field];
|
|
196
|
+
const value = parseChaosNumber(text, max, integer);
|
|
197
|
+
return value === void 0 ? `raw:${text}` : `num:${value}`;
|
|
198
|
+
}
|
|
199
|
+
/** Placeholder for a level that carries no config, so nothing can be warned about. */
|
|
200
|
+
const NO_SOURCE = { label: "(no chaos config)" };
|
|
201
|
+
/**
|
|
202
|
+
* Static chaos config (a fixture's `chaos`, a server default) is re-read on
|
|
203
|
+
* EVERY request, so an invalid value there used to warn on every request for
|
|
204
|
+
* the life of the process — thousands of identical lines for one typo. Latch
|
|
205
|
+
* those: once per distinct source+field+value, PER SERVER. Per-request HEADERS
|
|
206
|
+
* are dynamic input and keep warning per request, because each request really
|
|
207
|
+
* is a new caller making a new mistake.
|
|
208
|
+
*
|
|
209
|
+
* PER SERVER, not per process. The latch hangs off the server's `Logger` — one
|
|
210
|
+
* is constructed per `createServer` and every handler warns through
|
|
211
|
+
* `defaults.logger`, so the logger IS the server instance as far as this
|
|
212
|
+
* module can see it. A module-global `Set` instead meant two `LLMock`s in one
|
|
213
|
+
* process shared one latch: the second server never reported its own bad
|
|
214
|
+
* value, and one server's `resetChaosWarnings` re-armed the other's. A
|
|
215
|
+
* `WeakMap` so a stopped server's keys go with it — nothing else holds them.
|
|
216
|
+
*
|
|
217
|
+
* Cleared by `resetChaosWarnings`, which `performFullReset` calls alongside the
|
|
218
|
+
* other per-server state, so a suite that resets between tests sees the warning
|
|
219
|
+
* again rather than inheriting the previous test's latch.
|
|
220
|
+
*/
|
|
221
|
+
const warnedStaticRejections = /* @__PURE__ */ new WeakMap();
|
|
222
|
+
/**
|
|
223
|
+
* Cap on distinct latched keys per server. Runtime chaos installs (`POST
|
|
224
|
+
* /__aimock/chaos`, once per test in a long-lived harness) can mint unboundedly
|
|
225
|
+
* many distinct bad values, and a latch that only ever grows is a leak. Past
|
|
226
|
+
* the cap the OLDEST key is evicted (a `Set` iterates in insertion order), so
|
|
227
|
+
* the worst case is that a long-since-warned typo warns a second time — never
|
|
228
|
+
* unbounded memory.
|
|
229
|
+
*/
|
|
230
|
+
const MAX_LATCHED_REJECTIONS = 64;
|
|
231
|
+
/**
|
|
232
|
+
* Clear ONE server's static-config rejection latch, so the next request reports
|
|
233
|
+
* a bad static value again. See `warnedStaticRejections`.
|
|
234
|
+
*
|
|
235
|
+
* Takes that server's logger because the logger is what the latch is keyed by.
|
|
236
|
+
* There is deliberately no "clear every server" form: `performFullReset` is an
|
|
237
|
+
* isolation barrier for the server being reset, and reaching into a concurrent
|
|
238
|
+
* server's latch is exactly the cross-talk this scoping removes.
|
|
239
|
+
*
|
|
240
|
+
* Does NOT clear the `applyChaos` deprecation notice (`warnedSyncApplyChaos`),
|
|
241
|
+
* which is once per process by design — see the latch there.
|
|
242
|
+
*/
|
|
243
|
+
function resetChaosWarnings(logger) {
|
|
244
|
+
warnedStaticRejections.delete(logger);
|
|
245
|
+
}
|
|
246
|
+
function warnRejected(source, field, raw, logger) {
|
|
247
|
+
if (!logger) return;
|
|
248
|
+
if (source.latchKey !== void 0) {
|
|
249
|
+
const key = `${source.latchKey}\u0000${field}\u0000${JSON.stringify(raw)}`;
|
|
250
|
+
let latched = warnedStaticRejections.get(logger);
|
|
251
|
+
if (!latched) {
|
|
252
|
+
latched = /* @__PURE__ */ new Set();
|
|
253
|
+
warnedStaticRejections.set(logger, latched);
|
|
57
254
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (val < 0 || val > 1) logger?.warn(`[chaos] x-aimock-chaos-disconnect: value ${val} out of range [0,1], clamping`);
|
|
63
|
-
base.disconnectRate = Math.min(1, Math.max(0, val));
|
|
64
|
-
}
|
|
255
|
+
if (latched.has(key)) return;
|
|
256
|
+
if (latched.size >= MAX_LATCHED_REJECTIONS) {
|
|
257
|
+
const oldest = latched.values().next();
|
|
258
|
+
if (!oldest.done) latched.delete(oldest.value);
|
|
65
259
|
}
|
|
260
|
+
latched.add(key);
|
|
261
|
+
}
|
|
262
|
+
const shape = CHAOS_FIELDS[field].integer ? "a whole number of ms" : "a number";
|
|
263
|
+
logger.warn(`[chaos] ${source.label}: rejected ${field} value ${JSON.stringify(raw)} — must be ${shape} in [0,${CHAOS_FIELDS[field].max}]; ignoring it`);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Serial number per fixture OBJECT, minted on demand, so a fixture's latch key
|
|
267
|
+
* is its identity rather than its (possibly colliding) description. Same reason
|
|
268
|
+
* `router.ts` keys its relaxed-turnIndex throttle by object identity. A
|
|
269
|
+
* `WeakMap`, so the serial dies with the fixture.
|
|
270
|
+
*/
|
|
271
|
+
const fixtureSerials = /* @__PURE__ */ new WeakMap();
|
|
272
|
+
let nextFixtureSerial = 0;
|
|
273
|
+
/** Name and latch-identify the fixture whose `chaos` block is being read. */
|
|
274
|
+
function fixtureSource(fixture) {
|
|
275
|
+
let serial = fixtureSerials.get(fixture);
|
|
276
|
+
if (serial === void 0) {
|
|
277
|
+
serial = nextFixtureSerial++;
|
|
278
|
+
fixtureSerials.set(fixture, serial);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
label: `fixture chaos ${describeMatch(fixture.match, -1)}`,
|
|
282
|
+
latchKey: `fixture#${serial}`
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Name and latch-identify the server-level config this request resolved to. A
|
|
287
|
+
* per-testId override and the server-wide baseline are DIFFERENT sources — a
|
|
288
|
+
* typo in one must not latch the other silent — so the testId is part of both
|
|
289
|
+
* the label and the key.
|
|
290
|
+
*
|
|
291
|
+
* Mirrors the selection in `resolveScopedDefaults` (which is the authority):
|
|
292
|
+
* the baseline is the scope's `DEFAULT_TEST_ID` entry, so an untagged request
|
|
293
|
+
* reads as the plain server default rather than an override named after an
|
|
294
|
+
* internal sentinel.
|
|
295
|
+
*/
|
|
296
|
+
function serverSource(serverDefaults, rawHeaders, url) {
|
|
297
|
+
if (serverDefaults && isChaosScope(serverDefaults)) {
|
|
298
|
+
const testId = resolveTestId(rawHeaders ?? {}, url);
|
|
299
|
+
if (testId !== DEFAULT_TEST_ID && serverDefaults.byTestId?.has(testId)) return {
|
|
300
|
+
label: `server chaos default (testId ${JSON.stringify(testId)})`,
|
|
301
|
+
latchKey: `server:${testId}`
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
label: "server chaos default",
|
|
306
|
+
latchKey: "server"
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Read one field from the request headers. */
|
|
310
|
+
function readHeaderField(rawHeaders, field, logger) {
|
|
311
|
+
const text = firstHeaderValue(rawHeaders?.[CHAOS_FIELDS[field].header], field, logger);
|
|
312
|
+
if (text === void 0) return void 0;
|
|
313
|
+
const value = parseChaosField(field, text);
|
|
314
|
+
if (value === void 0) warnRejected({ label: CHAOS_FIELDS[field].header }, field, text, logger);
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
/** Read one field from a config object — a fixture's chaos, or the server default. */
|
|
318
|
+
function readConfigField(config, field, source, logger) {
|
|
319
|
+
const raw = config?.[field];
|
|
320
|
+
if (raw === void 0) return void 0;
|
|
321
|
+
const value = parseChaosField(field, raw);
|
|
322
|
+
if (value === void 0) warnRejected(source, field, raw, logger);
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Resolve chaos config from headers, fixture, and server defaults.
|
|
327
|
+
* Header values override fixture values, which override server defaults —
|
|
328
|
+
* field-wise at each step, so a level that sets only `dropRate` leaves the
|
|
329
|
+
* level below's `latencyMs` intact. The server defaults are themselves selected
|
|
330
|
+
* per-testId first, and THAT step replaces rather than merges (see
|
|
331
|
+
* `resolveScopedDefaults` and `ChaosScope`).
|
|
332
|
+
*
|
|
333
|
+
* Every source goes through the same parser and the same ONE out-of-range
|
|
334
|
+
* policy — reject and warn, never clamp (see `ChaosConfig`). A rejected value
|
|
335
|
+
* is simply not set, so the next level of precedence applies. All three levels
|
|
336
|
+
* are parsed even when a higher one wins, so a bad value is always reported
|
|
337
|
+
* rather than hidden by the override above it.
|
|
338
|
+
*
|
|
339
|
+
* Exported so a caller that needs BOTH the latency delay and the terminal
|
|
340
|
+
* action resolves once per request and threads the result through, instead of
|
|
341
|
+
* parsing (and re-warning about) the same headers twice on the hot path.
|
|
342
|
+
*/
|
|
343
|
+
function resolveChaosConfig(fixture, serverDefaults, rawHeaders, logger, url) {
|
|
344
|
+
const serverConfig = resolveScopedDefaults(serverDefaults, rawHeaders, url);
|
|
345
|
+
const resolved = {};
|
|
346
|
+
const fixtureFrom = fixture ? fixtureSource(fixture) : NO_SOURCE;
|
|
347
|
+
const serverFrom = serverConfig ? serverSource(serverDefaults, rawHeaders, url) : NO_SOURCE;
|
|
348
|
+
for (const field of CHAOS_FIELD_NAMES) {
|
|
349
|
+
const fromHeader = readHeaderField(rawHeaders, field, logger);
|
|
350
|
+
const fromFixture = readConfigField(fixture?.chaos, field, fixtureFrom, logger);
|
|
351
|
+
const fromServer = readConfigField(serverConfig, field, serverFrom, logger);
|
|
352
|
+
const value = fromHeader ?? fromFixture ?? fromServer;
|
|
353
|
+
if (value !== void 0) resolved[field] = value;
|
|
354
|
+
}
|
|
355
|
+
return resolved;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Resolve the deterministic latency delay (ms) for this request.
|
|
359
|
+
* Precedence is header > fixture > server, same as the rates. Returns 0
|
|
360
|
+
* when no latency is configured. Exported so async handlers can await the
|
|
361
|
+
* delay BEFORE evaluating terminal chaos actions.
|
|
362
|
+
*/
|
|
363
|
+
function resolveChaosLatencyMs(fixture, serverDefaults, rawHeaders, logger, url, resolved) {
|
|
364
|
+
return (resolved ?? resolveChaosConfig(fixture, serverDefaults, rawHeaders, logger, url)).latencyMs ?? 0;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Responses this module destroyed itself, through the `disconnect` chaos
|
|
368
|
+
* action. A `WeakSet` rather than a marker property on the response: it adds
|
|
369
|
+
* nothing to the object's shape (so a `ServerResponse` test double stays a
|
|
370
|
+
* faithful stand-in) and the entry goes away with the response.
|
|
371
|
+
*/
|
|
372
|
+
const chaosDisconnectedResponses = /* @__PURE__ */ new WeakSet();
|
|
373
|
+
/**
|
|
374
|
+
* The reason this response can no longer carry a fresh status line, or `null`
|
|
375
|
+
* while it can. Checked most-specific first: our own teardown, then an ended
|
|
376
|
+
* body, then a destroyed socket, then a committed status line.
|
|
377
|
+
*/
|
|
378
|
+
function responseUnwritableReason(res) {
|
|
379
|
+
if (chaosDisconnectedResponses.has(res)) return "chaos-disconnected";
|
|
380
|
+
if (res.writableEnded) return "already-ended";
|
|
381
|
+
if (res.destroyed) return "client-gone";
|
|
382
|
+
if (res.headersSent) return "headers-sent";
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The reason this response is GONE — dead for every purpose, chaos or not —
|
|
387
|
+
* or `null` while anything at all can still be done with it. `"headers-sent"`
|
|
388
|
+
* is deliberately NOT one of these.
|
|
389
|
+
*/
|
|
390
|
+
function responseGoneReason(res) {
|
|
391
|
+
const reason = responseUnwritableReason(res);
|
|
392
|
+
return reason === null || reason === "headers-sent" ? null : reason;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* True once nothing can be delivered on this response at all: the client hung
|
|
396
|
+
* up, the body was already ended, or our own `disconnect` action destroyed it.
|
|
397
|
+
*
|
|
398
|
+
* This is the predicate the streaming surfaces guard their post-`await` writes
|
|
399
|
+
* with (`grok-video.ts`, `veo-video.ts` — which hand-rolled
|
|
400
|
+
* `res.destroyed || res.writableEnded`, i.e. this minus the chaos-teardown
|
|
401
|
+
* case), and the one the SPLIT chaos gates in `src/server.ts` — the ones that
|
|
402
|
+
* call `awaitChaosLatency` and `evaluateChaos` separately instead of going
|
|
403
|
+
* through `applyChaosAsync` — have to re-check themselves after the await.
|
|
404
|
+
* Without that check a client that hangs up mid-delay still gets a full
|
|
405
|
+
* response built, "served" into a dead socket and JOURNALLED (C13).
|
|
406
|
+
*/
|
|
407
|
+
function responseGone(res) {
|
|
408
|
+
return responseGoneReason(res) !== null;
|
|
409
|
+
}
|
|
410
|
+
/** Human-readable gloss per reason, so a skipped chaos action logs what actually happened. */
|
|
411
|
+
const UNWRITABLE_DESCRIPTION = {
|
|
412
|
+
"chaos-disconnected": "the chaos disconnect action had already destroyed it",
|
|
413
|
+
"already-ended": "the response was already ended",
|
|
414
|
+
"client-gone": "the client disconnected",
|
|
415
|
+
"headers-sent": "a status line was already committed"
|
|
416
|
+
};
|
|
417
|
+
/** The gloss for one reason. Exported so the split gates in `src/server.ts` log the same words. */
|
|
418
|
+
function describeUnwritableReason(reason) {
|
|
419
|
+
return UNWRITABLE_DESCRIPTION[reason];
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Await the deterministic latency delay configured for this request, if any.
|
|
423
|
+
* Fixed (never randomised or jittered) so replay stays deterministic. Returns
|
|
424
|
+
* immediately when no latency is configured. Handlers that roll the chaos dice
|
|
425
|
+
* themselves (branching on the action before dispatching) call this first;
|
|
426
|
+
* handlers that just need "delay, then maybe fail" use `applyChaosAsync`.
|
|
427
|
+
*
|
|
428
|
+
* Pass `res` so a client that hangs up mid-delay CANCELS the pending timer
|
|
429
|
+
* (via the cancellable `delay(ms, signal)` helper) instead of leaving it armed
|
|
430
|
+
* for up to the 30s cap. The wait resolves early on disconnect — callers must
|
|
431
|
+
* re-check the response is still writable afterwards, which every chaos write
|
|
432
|
+
* path does through `responseUnwritable`.
|
|
433
|
+
*
|
|
434
|
+
* The disconnect signal is taken from the RESPONSE, not the request: an
|
|
435
|
+
* `IncomingMessage` is auto-destroyed as soon as its body has been fully read,
|
|
436
|
+
* so `req.destroyed` / `req`'s `close` event fire on every healthy request and
|
|
437
|
+
* would cancel the delay for everyone. `res`'s `close` before a write can only
|
|
438
|
+
* mean the connection was terminated prematurely.
|
|
439
|
+
*/
|
|
440
|
+
async function awaitChaosLatency(fixture, serverDefaults, rawHeaders, logger, url, res, resolved) {
|
|
441
|
+
const delayMs = resolveChaosLatencyMs(fixture, serverDefaults, rawHeaders, logger, url, resolved);
|
|
442
|
+
if (delayMs <= 0) return;
|
|
443
|
+
if (!res) {
|
|
444
|
+
await delay(delayMs);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const controller = new AbortController();
|
|
448
|
+
const onClose = () => controller.abort();
|
|
449
|
+
let unsubscribe;
|
|
450
|
+
if (responseGone(res)) controller.abort();
|
|
451
|
+
else if (typeof res.once === "function") {
|
|
452
|
+
res.once("close", onClose);
|
|
453
|
+
if (typeof res.off === "function") unsubscribe = () => res.off("close", onClose);
|
|
454
|
+
else if (typeof res.removeListener === "function") unsubscribe = () => res.removeListener("close", onClose);
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
await delay(delayMs, controller.signal);
|
|
458
|
+
} finally {
|
|
459
|
+
unsubscribe?.();
|
|
66
460
|
}
|
|
67
|
-
if (base.dropRate !== void 0) base.dropRate = Math.min(1, Math.max(0, base.dropRate));
|
|
68
|
-
if (base.malformedRate !== void 0) base.malformedRate = Math.min(1, Math.max(0, base.malformedRate));
|
|
69
|
-
if (base.disconnectRate !== void 0) base.disconnectRate = Math.min(1, Math.max(0, base.disconnectRate));
|
|
70
|
-
return base;
|
|
71
461
|
}
|
|
72
462
|
/**
|
|
73
463
|
* Evaluate chaos config and return the triggered action, or null if none.
|
|
74
|
-
*
|
|
464
|
+
*
|
|
465
|
+
* THE RATES DO NOT ROLL INDEPENDENTLY. Each is a separate `Math.random()` draw
|
|
466
|
+
* taken in a fixed order — drop → malformed → rateLimit → disconnect — and the
|
|
467
|
+
* first draw that hits returns immediately, so the ones after it are never
|
|
468
|
+
* rolled at all. A rate is therefore the probability that ITS draw hits GIVEN
|
|
469
|
+
* that no earlier rate fired, not the share of requests that end in that fault.
|
|
470
|
+
* Only `dropRate`, first in the chain, is unconditional.
|
|
471
|
+
*
|
|
472
|
+
* Worked example: `{ dropRate: 0.5, rateLimitRate: 0.5 }` yields ~50 % drops
|
|
473
|
+
* and ~25 % rate limits (0.5 x 0.5 — half the requests never reach the second
|
|
474
|
+
* draw), not 50 % of each. To exercise two faults at their nominal frequency,
|
|
475
|
+
* configure them on separate requests rather than in one config.
|
|
75
476
|
*/
|
|
76
|
-
function evaluateChaos(fixture, serverDefaults, rawHeaders, logger, url) {
|
|
77
|
-
const config = resolveChaosConfig(fixture, serverDefaults, rawHeaders, logger, url);
|
|
477
|
+
function evaluateChaos(fixture, serverDefaults, rawHeaders, logger, url, resolved) {
|
|
478
|
+
const config = resolved ?? resolveChaosConfig(fixture, serverDefaults, rawHeaders, logger, url);
|
|
78
479
|
if (config.dropRate !== void 0 && config.dropRate > 0 && Math.random() < config.dropRate) return "drop";
|
|
79
480
|
if (config.malformedRate !== void 0 && config.malformedRate > 0 && Math.random() < config.malformedRate) return "malformed";
|
|
481
|
+
if (config.rateLimitRate !== void 0 && config.rateLimitRate > 0 && Math.random() < config.rateLimitRate) return "rateLimit";
|
|
80
482
|
if (config.disconnectRate !== void 0 && config.disconnectRate > 0 && Math.random() < config.disconnectRate) return "disconnect";
|
|
81
483
|
return null;
|
|
82
484
|
}
|
|
83
485
|
/**
|
|
486
|
+
* Async chaos entrypoint: awaits the deterministic latency delay (when
|
|
487
|
+
* configured) BEFORE rolling terminal actions. Returns true when a terminal
|
|
488
|
+
* action fired (caller returns early), false to proceed.
|
|
489
|
+
*
|
|
490
|
+
* This is the ONLY entrypoint that honours `latencyMs`, so it is the one every
|
|
491
|
+
* route handler must use: `src/__tests__/chaos-async-call-sites.test.ts` fails
|
|
492
|
+
* the build if any module outside this file rolls chaos without an awaited
|
|
493
|
+
* latency dominating the roll. That guard skips this file wholesale — it
|
|
494
|
+
* defines the primitives — so the `applyChaos(...)` delegation below is neither
|
|
495
|
+
* checked by it nor a control for it. The guard proves it is not vacuous its
|
|
496
|
+
* own way: synthetic fixtures for each evasion shape, a floor on the number of
|
|
497
|
+
* files scanned and chaos imports resolved, and a reconciliation of its name
|
|
498
|
+
* sets against this file's real exports.
|
|
499
|
+
*
|
|
500
|
+
* The delay is tied to the response socket, so a client that disconnects during
|
|
501
|
+
* it cancels the timer. When that happens (or the response went away for any
|
|
502
|
+
* other reason while we waited) the dice are never rolled: returns
|
|
503
|
+
* `"unwritable"` so the caller returns early, with nothing written and nothing
|
|
504
|
+
* journalled — a response that was never sent must not appear in the journal.
|
|
505
|
+
*
|
|
506
|
+
* A response that merely has its HEADERS SENT is NOT gone, and is not
|
|
507
|
+
* short-circuited: the delay is still awaited and the dice are still rolled.
|
|
508
|
+
* `disconnect` needs no status line and is applied; the three body actions
|
|
509
|
+
* are skipped and the call reports `false`, because the caller still has a
|
|
510
|
+
* healthy response to finish and `if (outcome) return;` would abandon it.
|
|
511
|
+
*
|
|
512
|
+
* The return value is a {@link ChaosAsyncOutcome}, not a boolean, so callers
|
|
513
|
+
* can tell "chaos fired" from "the response was already dead" — two outcomes
|
|
514
|
+
* the old `true` conflated. Both are truthy, so the standard call shape
|
|
515
|
+
* `if (await applyChaosAsync(...)) return;` keeps working unchanged; a caller
|
|
516
|
+
* that cares compares against `"handled"` / `"unwritable"`.
|
|
517
|
+
*/
|
|
518
|
+
async function applyChaosAsync(res, fixture, serverDefaults, rawHeaders, requestUrl, journal, context, source, registry, logger) {
|
|
519
|
+
const resolved = resolveChaosConfig(fixture, serverDefaults, rawHeaders, logger, requestUrl);
|
|
520
|
+
await awaitChaosLatency(fixture, serverDefaults, rawHeaders, logger, requestUrl, res, resolved);
|
|
521
|
+
const gone = responseGoneReason(res);
|
|
522
|
+
if (gone !== null) {
|
|
523
|
+
logger?.debug(`[chaos] not applying chaos to ${context.method} ${context.path}: ${UNWRITABLE_DESCRIPTION[gone]} — nothing written, nothing journalled`);
|
|
524
|
+
return "unwritable";
|
|
525
|
+
}
|
|
526
|
+
return applyChaos(res, fixture, serverDefaults, rawHeaders, requestUrl, journal, context, source, registry, logger, resolved) ? "handled" : false;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
84
529
|
* Apply chaos to a request. Returns true if chaos was applied (caller should
|
|
85
530
|
* return early), false if the request should proceed normally.
|
|
86
531
|
*
|
|
532
|
+
* Synchronous, and therefore SKIPS the configured `latencyMs` delay — it cannot
|
|
533
|
+
* await. It IS exported (the async wrapper and this module's tests call it by
|
|
534
|
+
* name), but it is not the public surface: no handler may call it —
|
|
535
|
+
* `src/__tests__/chaos-async-call-sites.test.ts` fails the build if one does —
|
|
536
|
+
* and the barrel deliberately publishes `applyChaosDeprecated` under the name
|
|
537
|
+
* `applyChaos` instead, so library consumers who still call it are told about
|
|
538
|
+
* the latency gap. See both below.
|
|
539
|
+
*
|
|
87
540
|
* `requestUrl` is the RAW `req.url` (query string included) — chaos scoping
|
|
88
541
|
* resolves the testId from it exactly as `getTestId` does, so `?testId=` tagged
|
|
89
542
|
* traffic is not silently unscoped. It is required, not optional, so a new
|
|
@@ -93,11 +546,48 @@ function evaluateChaos(fixture, serverDefaults, rawHeaders, logger, url) {
|
|
|
93
546
|
* the <X> phase" is enforced at the type level. A future handler that grows
|
|
94
547
|
* a proxy path MUST pass `"proxy"` explicitly; the default can't drift silently.
|
|
95
548
|
*/
|
|
96
|
-
function applyChaos(res, fixture, serverDefaults, rawHeaders, requestUrl, journal, context, source, registry, logger) {
|
|
97
|
-
const action = evaluateChaos(fixture, serverDefaults, rawHeaders, logger, requestUrl);
|
|
549
|
+
function applyChaos(res, fixture, serverDefaults, rawHeaders, requestUrl, journal, context, source, registry, logger, resolved) {
|
|
550
|
+
const action = evaluateChaos(fixture, serverDefaults, rawHeaders, logger, requestUrl, resolved);
|
|
98
551
|
if (!action) return false;
|
|
99
|
-
applyChaosAction(action, res, fixture, journal, context, source, registry);
|
|
100
|
-
|
|
552
|
+
return applyChaosAction(action, res, fixture, journal, context, source, registry, logger) === "applied";
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* One-shot latch for the deprecation notice below: warn once per process, not
|
|
556
|
+
* per request.
|
|
557
|
+
*
|
|
558
|
+
* PER PROCESS, and deliberately not cleared by `resetChaosWarnings` /
|
|
559
|
+
* `performFullReset` — unlike `warnedStaticRejections`, which IS per server and
|
|
560
|
+
* IS cleared. The two latches guard different kinds of fact. A rejected static
|
|
561
|
+
* chaos value describes THIS server's configuration, which a reset may change,
|
|
562
|
+
* so a reset suite must hear about it again. This notice describes the
|
|
563
|
+
* EMBEDDER'S CODE — a call site that still imports the sync `applyChaos` — and
|
|
564
|
+
* a `reset()` cannot change that: re-emitting it per test would print the same
|
|
565
|
+
* unchanged migration instruction once per test with nothing new to say. The
|
|
566
|
+
* deprecation policy (`docs/deprecation-policy/index.html`) is about which
|
|
567
|
+
* upstream endpoints aimock keeps mocking and says nothing about notice
|
|
568
|
+
* frequency, so the choice is ours; the message states it ("Warned once per
|
|
569
|
+
* process.") so nobody reads the single line as "it only happened once".
|
|
570
|
+
*/
|
|
571
|
+
let warnedSyncApplyChaos = false;
|
|
572
|
+
/**
|
|
573
|
+
* Public, deprecated wrapper around the synchronous {@link applyChaos}. The
|
|
574
|
+
* barrel (`src/index.ts`) exports this under the name `applyChaos`, which has
|
|
575
|
+
* been part of the published API since v1.10.0 — removing it outright would
|
|
576
|
+
* break embedders silently, so it stays and says so instead.
|
|
577
|
+
*
|
|
578
|
+
* @deprecated Use {@link applyChaosAsync}. The synchronous form cannot await,
|
|
579
|
+
* so it silently ignores the configured chaos latency (`latencyMs`,
|
|
580
|
+
* `--chaos-latency`, `x-aimock-chaos-latency`): the fault rates still roll, but
|
|
581
|
+
* no delay is injected. Every fault behaviour is otherwise identical.
|
|
582
|
+
*/
|
|
583
|
+
function applyChaosDeprecated(res, fixture, serverDefaults, rawHeaders, requestUrl, journal, context, source, registry, logger) {
|
|
584
|
+
if (!warnedSyncApplyChaos) {
|
|
585
|
+
warnedSyncApplyChaos = true;
|
|
586
|
+
const notice = "applyChaos() is deprecated and ignores the configured chaos latency (latencyMs / --chaos-latency / x-aimock-chaos-latency): it is synchronous and cannot await the delay. Use `await applyChaosAsync(...)` instead — same arguments, same fault behaviour, plus the latency. (Warned once per process.)";
|
|
587
|
+
if (logger) logger.warn(notice);
|
|
588
|
+
else console.warn(`[aimock] ${notice}`);
|
|
589
|
+
}
|
|
590
|
+
return applyChaos(res, fixture, serverDefaults, rawHeaders, requestUrl, journal, context, source, registry, logger);
|
|
101
591
|
}
|
|
102
592
|
/**
|
|
103
593
|
* Apply a specific (already-rolled) chaos action. Exposed so callers that roll
|
|
@@ -107,16 +597,46 @@ function applyChaos(res, fixture, serverDefaults, rawHeaders, requestUrl, journa
|
|
|
107
597
|
*
|
|
108
598
|
* `source` is required (not optional) so callers can't silently omit it on
|
|
109
599
|
* one branch and journal an ambiguous entry. Pass `"fixture"` when a fixture
|
|
110
|
-
* matched (or would have)
|
|
111
|
-
*
|
|
600
|
+
* matched (or would have), `"proxy"` when the request was headed for the proxy
|
|
601
|
+
* path, and `"internal"` — the value most call sites pass — for aimock's own
|
|
602
|
+
* built-in endpoints, which are neither.
|
|
603
|
+
*
|
|
604
|
+
* A chaos action can be dispatched an arbitrarily long time after the request
|
|
605
|
+
* arrived (up to the 30s latency cap), by which point the client may be gone.
|
|
606
|
+
* Writing to a destroyed socket is silently lost, so counting the action and
|
|
607
|
+
* journalling a status the client never received would be a lie. When the
|
|
608
|
+
* response cannot take THIS action this is a no-op: no metric, no journal
|
|
609
|
+
* entry, no write, and the return value says `"skipped"`. The journal
|
|
610
|
+
* vocabulary has no "aborted" chaos status — the entry is omitted rather than
|
|
611
|
+
* invented.
|
|
612
|
+
*
|
|
613
|
+
* "Cannot take this action" is per-action, not one bit. A GONE response
|
|
614
|
+
* (client hung up, body ended, already chaos-destroyed) forecloses everything.
|
|
615
|
+
* A response whose HEADERS ARE SENT forecloses only the three actions that
|
|
616
|
+
* write a status line of their own — `disconnect` is a bare `res.destroy()`
|
|
617
|
+
* and is applied normally.
|
|
618
|
+
*
|
|
619
|
+
* `logger` is REQUIRED (not optional, not defaulted) because every branch
|
|
620
|
+
* above can decline to act, and a skip nobody can see is the bug this
|
|
621
|
+
* parameter exists to prevent: one call site had quietly omitted it. It is
|
|
622
|
+
* typed `Logger | undefined` rather than `Logger` because the public,
|
|
623
|
+
* deprecated `applyChaos` wrapper's own logger is optional and threads through
|
|
624
|
+
* here — arity is what the compiler enforces, and that is what catches the
|
|
625
|
+
* omission.
|
|
112
626
|
*/
|
|
113
|
-
function applyChaosAction(action, res, fixture, journal, context, source, registry) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
627
|
+
function applyChaosAction(action, res, fixture, journal, context, source, registry, logger) {
|
|
628
|
+
const unwritable = responseUnwritableReason(res);
|
|
629
|
+
if (unwritable !== null && (unwritable !== "headers-sent" || action !== "disconnect")) {
|
|
630
|
+
logger?.debug(`[chaos] skipping ${action} on ${context.method} ${context.path}: ${UNWRITABLE_DESCRIPTION[unwritable]} — no metric, no journal entry, no write`);
|
|
631
|
+
return "skipped";
|
|
632
|
+
}
|
|
118
633
|
switch (action) {
|
|
119
634
|
case "drop":
|
|
635
|
+
writeErrorResponse(res, 500, JSON.stringify({ error: {
|
|
636
|
+
message: "Chaos: request dropped",
|
|
637
|
+
type: "server_error",
|
|
638
|
+
code: "chaos_drop"
|
|
639
|
+
} }));
|
|
120
640
|
journal.add({
|
|
121
641
|
...context,
|
|
122
642
|
response: {
|
|
@@ -126,13 +646,10 @@ function applyChaosAction(action, res, fixture, journal, context, source, regist
|
|
|
126
646
|
source
|
|
127
647
|
}
|
|
128
648
|
});
|
|
129
|
-
|
|
130
|
-
message: "Chaos: request dropped",
|
|
131
|
-
type: "server_error",
|
|
132
|
-
code: "chaos_drop"
|
|
133
|
-
} }));
|
|
134
|
-
return;
|
|
649
|
+
break;
|
|
135
650
|
case "malformed":
|
|
651
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
652
|
+
res.end("{malformed json: <<<chaos>>>");
|
|
136
653
|
journal.add({
|
|
137
654
|
...context,
|
|
138
655
|
response: {
|
|
@@ -142,25 +659,47 @@ function applyChaosAction(action, res, fixture, journal, context, source, regist
|
|
|
142
659
|
source
|
|
143
660
|
}
|
|
144
661
|
});
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
662
|
+
break;
|
|
663
|
+
case "rateLimit":
|
|
664
|
+
writeErrorResponse(res, 429, JSON.stringify({ error: {
|
|
665
|
+
message: "Chaos: rate limit exceeded",
|
|
666
|
+
type: "rate_limit_error",
|
|
667
|
+
code: "chaos_ratelimit"
|
|
668
|
+
} }));
|
|
149
669
|
journal.add({
|
|
150
670
|
...context,
|
|
151
671
|
response: {
|
|
152
|
-
status:
|
|
672
|
+
status: 429,
|
|
153
673
|
fixture,
|
|
154
|
-
chaosAction: "
|
|
674
|
+
chaosAction: "rateLimit",
|
|
155
675
|
source
|
|
156
676
|
}
|
|
157
677
|
});
|
|
678
|
+
break;
|
|
679
|
+
case "disconnect": {
|
|
680
|
+
const status = res.headersSent ? res.statusCode : 0;
|
|
681
|
+
chaosDisconnectedResponses.add(res);
|
|
158
682
|
res.destroy();
|
|
159
|
-
|
|
160
|
-
|
|
683
|
+
journal.add({
|
|
684
|
+
...context,
|
|
685
|
+
response: {
|
|
686
|
+
status,
|
|
687
|
+
fixture,
|
|
688
|
+
chaosAction: "disconnect",
|
|
689
|
+
source
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
default: return "skipped";
|
|
161
695
|
}
|
|
696
|
+
registry?.incrementCounter("aimock_chaos_triggered_total", {
|
|
697
|
+
action,
|
|
698
|
+
source
|
|
699
|
+
});
|
|
700
|
+
return "applied";
|
|
162
701
|
}
|
|
163
702
|
|
|
164
703
|
//#endregion
|
|
165
|
-
export {
|
|
704
|
+
export { CHAOS_FIELDS, CHAOS_FIELD_NAMES, applyChaosAction, applyChaosAsync, applyChaosDeprecated, awaitChaosLatency, describeUnwritableReason, evaluateChaos, isChaosScope, parseChaosField, resetChaosWarnings, resolveChaosConfig, resolveChaosLatencyMs, responseGone, responseGoneReason };
|
|
166
705
|
//# sourceMappingURL=chaos.js.map
|