@yolk-sdk/emulators 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture-only route core (internal; not a package export).
|
|
3
|
+
*
|
|
4
|
+
* The owner rule for every route added after the model-route emulators (the four OpenCode Go
|
|
5
|
+
* routes and the Claude, Codex, and Grok subscription-usage routes): response behaviour comes only
|
|
6
|
+
* from the committed conformance fixtures.
|
|
7
|
+
*
|
|
8
|
+
* - A request that matches a recorded request's shape (within the documented request-shape
|
|
9
|
+
* latitude below) gets that recording's response, copied as data (`*-recordings.ts`; never an
|
|
10
|
+
* SDK import).
|
|
11
|
+
* - Everything else answers one ledgered 400 not-emulated: unknown routes, other methods, missing
|
|
12
|
+
* or invalid credentials, missing or other headers and query parameters, unknown models, other
|
|
13
|
+
* request shapes (non-streamed modes, tools, reasoning, other message kinds, extra fields).
|
|
14
|
+
* - Test controls, as far as the other emulators allow them: the shared kernel faults (`status`,
|
|
15
|
+
* `error-after-chunks`, `truncate-after-chunks`) and scripted error turns; a route with a
|
|
16
|
+
* replaceable JSON body (the usage routes) also takes a scripted `{ usage }` body (or a default
|
|
17
|
+
* override) whose JSON shape (object keys and value kinds) equals the recording's. Fault and
|
|
18
|
+
* scripted-error statuses must be 400-599, so no control can produce a success status or
|
|
19
|
+
* a body other than the recorded one (or a prefix of it cut by the truncation faults).
|
|
20
|
+
*
|
|
21
|
+
* Request-shape latitude (accepted, harmless): any credential value (never checked or stored);
|
|
22
|
+
* extra request headers; key order; every string value except the discriminators `model`, `role`,
|
|
23
|
+
* `type`, and `phase` (which must equal the recording); any positive integer where the recording
|
|
24
|
+
* has a number (the output-token limit). Array lengths, object keys, booleans (`stream`, `store`,
|
|
25
|
+
* `include_usage`, `parallel_tool_calls`, `additionalProperties`), the recorded `accept` value,
|
|
26
|
+
* the `content-type` media type, and the query string (exactly, byte for byte; a bare `?` counts as no query) must equal the
|
|
27
|
+
* recording. Route-specific header values (for example Grok's `x-grok-client-version`) are
|
|
28
|
+
* documented by the route that declares them.
|
|
29
|
+
*
|
|
30
|
+
* Runtime-portable Web APIs only; no Node builtins and no SDK imports.
|
|
31
|
+
*/
|
|
32
|
+
import { Predicate } from 'effect'
|
|
33
|
+
import * as Schema from 'effect/Schema'
|
|
34
|
+
import {
|
|
35
|
+
EmulatorErrorAfterChunksFault,
|
|
36
|
+
EmulatorScriptedError,
|
|
37
|
+
EmulatorStatusFault,
|
|
38
|
+
EmulatorTruncateAfterChunksFault,
|
|
39
|
+
isJsonObject,
|
|
40
|
+
jsonResponse,
|
|
41
|
+
makeEmulatorKernel,
|
|
42
|
+
parseJson,
|
|
43
|
+
readText,
|
|
44
|
+
stringField,
|
|
45
|
+
type EmulatorApi,
|
|
46
|
+
type KernelLedgerEntry
|
|
47
|
+
} from './emulator-kernel.ts'
|
|
48
|
+
import {
|
|
49
|
+
emulatorRouteKey,
|
|
50
|
+
type EmulatorEvidence,
|
|
51
|
+
type EmulatorRouteEvidence
|
|
52
|
+
} from './route-evidence.ts'
|
|
53
|
+
|
|
54
|
+
/** One committed fixture exchange, copied as data. */
|
|
55
|
+
export type FixtureRecording = {
|
|
56
|
+
readonly fixtureId: string
|
|
57
|
+
readonly caseId: string
|
|
58
|
+
readonly request: {
|
|
59
|
+
readonly method: string
|
|
60
|
+
readonly path: string
|
|
61
|
+
/** The recorded query string (with `?`), or empty. */
|
|
62
|
+
readonly query: string
|
|
63
|
+
/** The recorded (non-credential) request headers, lower-case. */
|
|
64
|
+
readonly headers: Readonly<Record<string, string>>
|
|
65
|
+
readonly body?: Schema.Json
|
|
66
|
+
}
|
|
67
|
+
readonly response: {
|
|
68
|
+
readonly status: number
|
|
69
|
+
readonly headers: Readonly<Record<string, string>>
|
|
70
|
+
/** Whether the fixture recorded network chunks (a stream) or one body. */
|
|
71
|
+
readonly streamed: boolean
|
|
72
|
+
/** Network chunks as recorded; a body is one chunk. */
|
|
73
|
+
readonly chunks: ReadonlyArray<string>
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fault and scripted-error statuses on fixture-only routes: errors only (400-599), so no control
|
|
78
|
+
// answers a success that no fixture records. One range check, so a rejection names the range
|
|
79
|
+
// actually accepted (400-599 holds no 204, 205, or 3xx, which the shared schema excludes).
|
|
80
|
+
const FixtureRouteErrorStatus = Schema.Int.check(Schema.isBetween({ minimum: 400, maximum: 599 }))
|
|
81
|
+
|
|
82
|
+
const FixtureRouteStatusFault = Schema.Struct({
|
|
83
|
+
...EmulatorStatusFault.fields,
|
|
84
|
+
status: FixtureRouteErrorStatus
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const FixtureRouteScriptedError = Schema.Struct({
|
|
88
|
+
error: Schema.Struct({
|
|
89
|
+
...EmulatorScriptedError.fields.error.fields,
|
|
90
|
+
status: FixtureRouteErrorStatus
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Faults: the shared kernel kinds only, with `status` limited to 400-599. `status` without a
|
|
96
|
+
* `body` answers an emulator-fault body (`{ error: { type: 'emulator_fault', message } }`), never
|
|
97
|
+
* a guessed provider envelope.
|
|
98
|
+
*/
|
|
99
|
+
export const FixtureRouteFault = Schema.Union([
|
|
100
|
+
FixtureRouteStatusFault,
|
|
101
|
+
EmulatorErrorAfterChunksFault,
|
|
102
|
+
EmulatorTruncateAfterChunksFault
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
export type FixtureRouteFault = typeof FixtureRouteFault.Type
|
|
106
|
+
|
|
107
|
+
export type FixtureRouteFaultKind = FixtureRouteFault['kind']
|
|
108
|
+
|
|
109
|
+
/** A scripted replacement body (replaceable-body routes only), shape-checked against the recording. */
|
|
110
|
+
export type FixtureRouteScriptedBody = { readonly usage: Schema.Json }
|
|
111
|
+
|
|
112
|
+
/** A scripted turn: an error response, or (replaceable-body routes only) a replacement body. */
|
|
113
|
+
export type FixtureRouteScriptedTurn = EmulatorScriptedError | FixtureRouteScriptedBody
|
|
114
|
+
|
|
115
|
+
export type FixtureRouteLedgerEntry = {
|
|
116
|
+
/** 1-based arrival order since the last ledger clear or reset. */
|
|
117
|
+
readonly seq: number
|
|
118
|
+
readonly method: string
|
|
119
|
+
readonly path: string
|
|
120
|
+
/** The request's query string (with `?`), when it has one. */
|
|
121
|
+
readonly query?: string
|
|
122
|
+
/** Parsed JSON request body, when it was valid JSON. */
|
|
123
|
+
readonly body?: Schema.Json
|
|
124
|
+
readonly model?: string
|
|
125
|
+
/** Which credential header carried a non-empty value (the value is never recorded). */
|
|
126
|
+
readonly credentialHeader?: 'authorization' | 'x-api-key'
|
|
127
|
+
/** Values of the non-credential headers the route records (lower-case names). */
|
|
128
|
+
readonly headers: Readonly<Record<string, string>>
|
|
129
|
+
/** The fixture id whose recorded response answered the request. */
|
|
130
|
+
readonly recording?: string
|
|
131
|
+
/** Why the request was answered 400 not-emulated. */
|
|
132
|
+
readonly notEmulated?: string
|
|
133
|
+
/** Kind of the fault that shaped the response. */
|
|
134
|
+
readonly fault?: FixtureRouteFaultKind
|
|
135
|
+
/** Why a matching fault could not take effect (the response was a 500 emulator error). */
|
|
136
|
+
readonly faultError?: string
|
|
137
|
+
/** Set when the emulator could not build the planned response (answered 500). */
|
|
138
|
+
readonly responseError?: string
|
|
139
|
+
/** Set when a scripted turn answered the request. */
|
|
140
|
+
readonly scripted?: 'usage' | 'error'
|
|
141
|
+
/** Evidence of the matched route; `unknown-route` for requests on no route. */
|
|
142
|
+
readonly evidence: EmulatorEvidence | 'unknown-route'
|
|
143
|
+
readonly status: number
|
|
144
|
+
/** Body chunks handed to the transport so far (each produced when pulled). */
|
|
145
|
+
readonly bodyChunks: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type FixtureRouteEmulator = EmulatorApi<
|
|
149
|
+
FixtureRouteScriptedTurn,
|
|
150
|
+
FixtureRouteFault,
|
|
151
|
+
FixtureRouteLedgerEntry
|
|
152
|
+
>
|
|
153
|
+
|
|
154
|
+
/** A header every request must carry (non-empty and, when set, accepted by `accepts`). */
|
|
155
|
+
export type FixtureRouteHeaderRule = {
|
|
156
|
+
/** Lower-case header name. */
|
|
157
|
+
readonly name: string
|
|
158
|
+
/** Keep the value in the ledger (never for credential or account headers). */
|
|
159
|
+
readonly record: boolean
|
|
160
|
+
readonly accepts?: (value: string) => boolean
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type FixtureRouteConfig = {
|
|
164
|
+
readonly method: 'GET' | 'POST'
|
|
165
|
+
readonly path: string
|
|
166
|
+
/** Route evidence manifest; must list exactly this route. */
|
|
167
|
+
readonly routes: ReadonlyArray<EmulatorRouteEvidence>
|
|
168
|
+
/** The recordings this route answers from (at least one), tried in order. */
|
|
169
|
+
readonly recordings: ReadonlyArray<FixtureRecording>
|
|
170
|
+
/** The credential header the SDK sends: a non-empty Bearer, or a non-empty `x-api-key`. */
|
|
171
|
+
readonly credential: 'bearer' | 'x-api-key'
|
|
172
|
+
/** Headers the SDK sends besides the credential and the recorded ones, required in order. */
|
|
173
|
+
readonly headers: ReadonlyArray<FixtureRouteHeaderRule>
|
|
174
|
+
/**
|
|
175
|
+
* Replaceable JSON body (usage routes): enables the scripted `{ usage }` turn, and
|
|
176
|
+
* `defaultBody` replaces the recorded body; both must have the recorded body's JSON shape.
|
|
177
|
+
*/
|
|
178
|
+
readonly replaceableBody?: { readonly defaultBody: Schema.Json | undefined }
|
|
179
|
+
/** Builds the error thrown by the JS API (and at construction) for invalid input. */
|
|
180
|
+
readonly inputInvalid: (input: 'fault' | 'turn', reason: string) => Error
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The one answer for everything no fixture records. */
|
|
184
|
+
export const notEmulatedResponse = (reason: string): Response =>
|
|
185
|
+
jsonResponse(400, { error: { type: 'not_emulated', message: `Not emulated: ${reason}` } })
|
|
186
|
+
|
|
187
|
+
const faultBody = (status: number): Schema.Json => ({
|
|
188
|
+
error: { type: 'emulator_fault', message: `Emulator fault: status ${status}.` }
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const discriminators: ReadonlyArray<string> = ['model', 'role', 'type', 'phase']
|
|
192
|
+
|
|
193
|
+
const kindOf = (value: Schema.Json): string => {
|
|
194
|
+
if (value === null) return 'null'
|
|
195
|
+
|
|
196
|
+
if (Array.isArray(value)) return 'array'
|
|
197
|
+
|
|
198
|
+
if (Predicate.isString(value)) return 'string'
|
|
199
|
+
|
|
200
|
+
if (Predicate.isNumber(value)) return 'number'
|
|
201
|
+
|
|
202
|
+
return Predicate.isBoolean(value) ? 'boolean' : 'object'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Where `actual` leaves the recorded request shape (the documented latitude applied), or
|
|
207
|
+
* undefined when it matches.
|
|
208
|
+
*/
|
|
209
|
+
export const recordedRequestMismatch = (
|
|
210
|
+
actual: Schema.Json | undefined,
|
|
211
|
+
recorded: Schema.Json,
|
|
212
|
+
path = '$',
|
|
213
|
+
key = ''
|
|
214
|
+
): string | undefined => {
|
|
215
|
+
if (actual === undefined) return `${path} is missing`
|
|
216
|
+
|
|
217
|
+
if (isJsonObject(recorded)) {
|
|
218
|
+
if (!isJsonObject(actual)) return `${path} is not an object`
|
|
219
|
+
|
|
220
|
+
const recordedKeys = Object.keys(recorded).sort()
|
|
221
|
+
const actualKeys = Object.keys(actual).sort()
|
|
222
|
+
|
|
223
|
+
const extra = actualKeys.filter(name => !recordedKeys.includes(name))
|
|
224
|
+
const missing = recordedKeys.filter(name => !actualKeys.includes(name))
|
|
225
|
+
|
|
226
|
+
if (extra.length > 0) return `${path} has unrecorded field(s) ${extra.join(', ')}`
|
|
227
|
+
|
|
228
|
+
if (missing.length > 0) return `${path} lacks recorded field(s) ${missing.join(', ')}`
|
|
229
|
+
|
|
230
|
+
for (const name of recordedKeys) {
|
|
231
|
+
const field = recorded[name]
|
|
232
|
+
|
|
233
|
+
if (field === undefined) continue
|
|
234
|
+
|
|
235
|
+
const mismatch = recordedRequestMismatch(actual[name], field, `${path}.${name}`, name)
|
|
236
|
+
|
|
237
|
+
if (mismatch !== undefined) return mismatch
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (Array.isArray(recorded)) {
|
|
244
|
+
if (!Array.isArray(actual)) return `${path} is not an array`
|
|
245
|
+
|
|
246
|
+
if (actual.length !== recorded.length) {
|
|
247
|
+
return `${path} has ${actual.length} item(s), the recording ${recorded.length}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const [index, item] of recorded.entries()) {
|
|
251
|
+
const mismatch = recordedRequestMismatch(actual[index], item, `${path}[${index}]`, key)
|
|
252
|
+
|
|
253
|
+
if (mismatch !== undefined) return mismatch
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (kindOf(actual) !== kindOf(recorded)) return `${path} is not a ${kindOf(recorded)}`
|
|
260
|
+
|
|
261
|
+
if (Predicate.isString(recorded)) {
|
|
262
|
+
return discriminators.includes(key) && actual !== recorded
|
|
263
|
+
? `${path} is not the recorded ${key}`
|
|
264
|
+
: undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (Predicate.isNumber(recorded)) {
|
|
268
|
+
return Predicate.isNumber(actual) && Number.isSafeInteger(actual) && actual > 0
|
|
269
|
+
? undefined
|
|
270
|
+
: `${path} is not a positive integer`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return actual === recorded ? undefined : `${path} is not the recorded value`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Where `actual` leaves the recorded JSON shape (object keys and value kinds), or undefined. */
|
|
277
|
+
export const recordedBodyMismatch = (
|
|
278
|
+
actual: Schema.Json,
|
|
279
|
+
recorded: Schema.Json,
|
|
280
|
+
path = '$'
|
|
281
|
+
): string | undefined => {
|
|
282
|
+
if (isJsonObject(recorded)) {
|
|
283
|
+
if (!isJsonObject(actual)) return `${path} is not an object`
|
|
284
|
+
|
|
285
|
+
const recordedKeys = Object.keys(recorded).sort()
|
|
286
|
+
|
|
287
|
+
if (JSON.stringify(Object.keys(actual).sort()) !== JSON.stringify(recordedKeys)) {
|
|
288
|
+
return `${path} does not have the recorded fields ${recordedKeys.join(', ')}`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const name of recordedKeys) {
|
|
292
|
+
const actualField = actual[name]
|
|
293
|
+
const recordedField = recorded[name]
|
|
294
|
+
|
|
295
|
+
if (actualField === undefined || recordedField === undefined) continue
|
|
296
|
+
|
|
297
|
+
const mismatch = recordedBodyMismatch(actualField, recordedField, `${path}.${name}`)
|
|
298
|
+
|
|
299
|
+
if (mismatch !== undefined) return mismatch
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return undefined
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (Array.isArray(recorded)) {
|
|
306
|
+
if (!Array.isArray(actual) || actual.length !== recorded.length) {
|
|
307
|
+
return `${path} is not a ${recorded.length}-item array`
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const [index, item] of recorded.entries()) {
|
|
311
|
+
const actualItem = actual[index]
|
|
312
|
+
|
|
313
|
+
if (actualItem === undefined) return `${path}[${index}] is missing`
|
|
314
|
+
|
|
315
|
+
const mismatch = recordedBodyMismatch(actualItem, item, `${path}[${index}]`)
|
|
316
|
+
|
|
317
|
+
if (mismatch !== undefined) return mismatch
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return undefined
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return kindOf(actual) === kindOf(recorded) ? undefined : `${path} is not a ${kindOf(recorded)}`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A non-empty bearer credential. The value is never checked or stored.
|
|
327
|
+
const bearerPattern = /^bearer\s+\S+/i
|
|
328
|
+
|
|
329
|
+
const mediaType = (value: string | null): string | undefined =>
|
|
330
|
+
value?.split(';')[0]?.trim().toLowerCase()
|
|
331
|
+
|
|
332
|
+
type MutableLedgerEntry = KernelLedgerEntry<FixtureRouteFaultKind> & {
|
|
333
|
+
query?: string
|
|
334
|
+
body?: Schema.Json
|
|
335
|
+
model?: string
|
|
336
|
+
credentialHeader?: 'authorization' | 'x-api-key'
|
|
337
|
+
headers: Record<string, string>
|
|
338
|
+
recording?: string
|
|
339
|
+
notEmulated?: string
|
|
340
|
+
scripted?: 'usage' | 'error'
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create a fixture-only route. Each call has independent ledger, fault, and script state. Throws
|
|
345
|
+
* `inputInvalid('turn', ...)` when a replaceable body's `defaultBody` does not have the recorded
|
|
346
|
+
* shape.
|
|
347
|
+
*
|
|
348
|
+
* Precedence per request: the credential, the header rules in order, the recorded headers and
|
|
349
|
+
* query, the recorded request shape (one of `recordings`); a request failing any of these answers
|
|
350
|
+
* 400 not-emulated and uses up no fault or turn. Then the first matching fault if it is a `status`
|
|
351
|
+
* fault, then the next scripted turn, then the recorded response; a first matching body fault then
|
|
352
|
+
* shapes the body.
|
|
353
|
+
*/
|
|
354
|
+
export const makeFixtureRouteEmulator = (config: FixtureRouteConfig): FixtureRouteEmulator => {
|
|
355
|
+
const [primary] = config.recordings
|
|
356
|
+
|
|
357
|
+
if (primary === undefined) {
|
|
358
|
+
throw config.inputInvalid('turn', 'a fixture route needs at least one recording')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const recordedBody =
|
|
362
|
+
config.replaceableBody === undefined ? undefined : parseJson(primary.response.chunks.join(''))
|
|
363
|
+
|
|
364
|
+
const bodyMismatch = (value: Schema.Json): string | undefined =>
|
|
365
|
+
recordedBody === undefined
|
|
366
|
+
? 'this route has no replaceable body'
|
|
367
|
+
: recordedBodyMismatch(value, recordedBody)
|
|
368
|
+
|
|
369
|
+
const defaultBody = config.replaceableBody?.defaultBody
|
|
370
|
+
|
|
371
|
+
if (defaultBody !== undefined) {
|
|
372
|
+
const mismatch = bodyMismatch(defaultBody)
|
|
373
|
+
|
|
374
|
+
if (mismatch !== undefined) {
|
|
375
|
+
throw config.inputInvalid(
|
|
376
|
+
'turn',
|
|
377
|
+
`the default body must have the recorded shape: ${mismatch}`
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const ScriptedBody = Schema.Struct({
|
|
383
|
+
usage: Schema.Json.check(
|
|
384
|
+
Schema.makeFilter(value => {
|
|
385
|
+
const mismatch = bodyMismatch(value)
|
|
386
|
+
|
|
387
|
+
return mismatch === undefined ? true : `must have the recorded shape: ${mismatch}`
|
|
388
|
+
})
|
|
389
|
+
)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const turnSchema: Schema.Decoder<FixtureRouteScriptedTurn> =
|
|
393
|
+
config.replaceableBody === undefined
|
|
394
|
+
? FixtureRouteScriptedError
|
|
395
|
+
: Schema.Union([FixtureRouteScriptedError, ScriptedBody])
|
|
396
|
+
|
|
397
|
+
const models = config.recordings.flatMap(recording => {
|
|
398
|
+
const model = stringField(recording.request.body, 'model')
|
|
399
|
+
|
|
400
|
+
return model === undefined ? [] : [model]
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const kernel = makeEmulatorKernel({
|
|
404
|
+
routes: config.routes,
|
|
405
|
+
faultSchema: FixtureRouteFault,
|
|
406
|
+
turnSchema,
|
|
407
|
+
newEntry: (base): MutableLedgerEntry => ({ ...base, headers: {} }),
|
|
408
|
+
snapshotEntry: (entry): FixtureRouteLedgerEntry => ({
|
|
409
|
+
...entry,
|
|
410
|
+
headers: { ...entry.headers }
|
|
411
|
+
}),
|
|
412
|
+
unknownRoute: entry => {
|
|
413
|
+
entry.notEmulated = 'no recorded route for this method and path'
|
|
414
|
+
|
|
415
|
+
return notEmulatedResponse(entry.notEmulated)
|
|
416
|
+
},
|
|
417
|
+
stateFields: () => ({ knownModels: [...new Set(models)] }),
|
|
418
|
+
inputInvalid: config.inputInvalid
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
const recordingMismatch = (
|
|
422
|
+
recording: FixtureRecording,
|
|
423
|
+
request: Request,
|
|
424
|
+
url: URL,
|
|
425
|
+
text: string,
|
|
426
|
+
json: Schema.Json | undefined
|
|
427
|
+
): string | undefined => {
|
|
428
|
+
for (const [name, value] of Object.entries(recording.request.headers)) {
|
|
429
|
+
const actual = request.headers.get(name)
|
|
430
|
+
|
|
431
|
+
const matches =
|
|
432
|
+
name === 'content-type' ? mediaType(actual) === mediaType(value) : actual === value
|
|
433
|
+
|
|
434
|
+
if (!matches) return `the ${name} header is not the recorded ${value}`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (url.search !== recording.request.query) {
|
|
438
|
+
return recording.request.query.length === 0
|
|
439
|
+
? 'the recording has no query parameters'
|
|
440
|
+
: `the query is not the recorded ${recording.request.query}`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (recording.request.body === undefined) {
|
|
444
|
+
return text.length === 0 ? undefined : 'the recording has no request body'
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (json === undefined) return 'the request body is not JSON'
|
|
448
|
+
|
|
449
|
+
return recordedRequestMismatch(json, recording.request.body)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const admit = async (
|
|
453
|
+
request: Request,
|
|
454
|
+
entry: MutableLedgerEntry
|
|
455
|
+
): Promise<{ readonly recording: FixtureRecording } | { readonly reason: string }> => {
|
|
456
|
+
const credential =
|
|
457
|
+
config.credential === 'bearer'
|
|
458
|
+
? bearerPattern.test(request.headers.get('authorization') ?? '')
|
|
459
|
+
: (request.headers.get('x-api-key') ?? '').trim().length > 0
|
|
460
|
+
|
|
461
|
+
if (!credential) {
|
|
462
|
+
return {
|
|
463
|
+
reason:
|
|
464
|
+
config.credential === 'bearer'
|
|
465
|
+
? 'a non-empty Authorization: Bearer credential is required'
|
|
466
|
+
: 'a non-empty x-api-key credential is required'
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
entry.credentialHeader = config.credential === 'bearer' ? 'authorization' : 'x-api-key'
|
|
471
|
+
|
|
472
|
+
for (const rule of config.headers) {
|
|
473
|
+
const value = (request.headers.get(rule.name) ?? '').trim()
|
|
474
|
+
|
|
475
|
+
if (value.length === 0 || (rule.accepts !== undefined && !rule.accepts(value))) {
|
|
476
|
+
return { reason: `the ${rule.name} header is missing or not the one the SDK sends` }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const url = new URL(request.url)
|
|
481
|
+
const text = (await readText(request)) ?? ''
|
|
482
|
+
|
|
483
|
+
const json = config.method === 'POST' ? parseJson(text) : undefined
|
|
484
|
+
|
|
485
|
+
if (json !== undefined) entry.body = json
|
|
486
|
+
|
|
487
|
+
const model = stringField(json, 'model')
|
|
488
|
+
|
|
489
|
+
if (model !== undefined) entry.model = model
|
|
490
|
+
|
|
491
|
+
const reasons: Array<string> = []
|
|
492
|
+
|
|
493
|
+
for (const recording of config.recordings) {
|
|
494
|
+
const reason = recordingMismatch(recording, request, url, text, json)
|
|
495
|
+
|
|
496
|
+
if (reason === undefined) return { recording }
|
|
497
|
+
|
|
498
|
+
reasons.push(config.recordings.length === 1 ? reason : `${recording.caseId}: ${reason}`)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { reason: reasons.join('; ') }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const handle = async (
|
|
505
|
+
request: Request,
|
|
506
|
+
entry: MutableLedgerEntry,
|
|
507
|
+
path: string
|
|
508
|
+
): Promise<Response> => {
|
|
509
|
+
for (const rule of config.headers) {
|
|
510
|
+
const value = request.headers.get(rule.name)
|
|
511
|
+
|
|
512
|
+
if (rule.record && value !== null) entry.headers[rule.name] = value
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const search = new URL(request.url).search
|
|
516
|
+
|
|
517
|
+
if (search.length > 0) entry.query = search
|
|
518
|
+
|
|
519
|
+
const admitted = await admit(request, entry)
|
|
520
|
+
|
|
521
|
+
if ('reason' in admitted) {
|
|
522
|
+
entry.notEmulated = admitted.reason
|
|
523
|
+
entry.status = 400
|
|
524
|
+
|
|
525
|
+
return notEmulatedResponse(admitted.reason)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const recording = admitted.recording
|
|
529
|
+
const fault = kernel.takeFault(path, entry.model)
|
|
530
|
+
|
|
531
|
+
if (fault !== undefined && fault.fault.kind === 'status') {
|
|
532
|
+
return kernel.respondWithStatusFault(entry, fault, fault.fault, faultBody)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const turn = kernel.nextTurn()
|
|
536
|
+
|
|
537
|
+
if (turn !== undefined && 'error' in turn) {
|
|
538
|
+
entry.scripted = 'error'
|
|
539
|
+
|
|
540
|
+
return kernel.respondWithScriptedError(entry, turn.error, fault)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const body = turn === undefined ? defaultBody : turn.usage
|
|
544
|
+
|
|
545
|
+
if (turn !== undefined) entry.scripted = 'usage'
|
|
546
|
+
|
|
547
|
+
entry.recording = recording.fixtureId
|
|
548
|
+
|
|
549
|
+
return kernel.respondWithBody(
|
|
550
|
+
entry,
|
|
551
|
+
recording.response.status,
|
|
552
|
+
{ ...recording.response.headers },
|
|
553
|
+
body === undefined ? recording.response.chunks : [JSON.stringify(body)],
|
|
554
|
+
fault
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Every manifest route maps to its own handler; construction throws `EmulatorRouteUnmapped`
|
|
559
|
+
// when a manifest route has no handler (or a handler has no manifest route).
|
|
560
|
+
return kernel.serve(new Map([[emulatorRouteKey(config.method, config.path), handle]]))
|
|
561
|
+
}
|