@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.
Files changed (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. 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
+ }