@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
package/README.md
ADDED
|
@@ -0,0 +1,2135 @@
|
|
|
1
|
+
# @yolk-sdk/emulators
|
|
2
|
+
|
|
3
|
+
> **EXPERIMENTAL.** This package is new and its API may change in any canary release, beyond the
|
|
4
|
+
> usual canary instability.
|
|
5
|
+
|
|
6
|
+
Emulators for outside services, for tests and local development. A route table sends an Effect
|
|
7
|
+
`HttpClient` to an emulator instead of the real service. Two emulators speak the OpenAI-compatible
|
|
8
|
+
Chat Completions wire: the Vercel AI Gateway and OpenAI itself. A third emulates Anthropic Messages,
|
|
9
|
+
and two more speak the OpenAI Responses wire of the subscription providers: the ChatGPT Codex
|
|
10
|
+
endpoint and the xAI Grok CLI proxy. The OpenCode Go emulator answers the Go chat, Messages,
|
|
11
|
+
Responses, and usage routes under one origin, and the Anthropic, Codex, and Grok emulators also
|
|
12
|
+
answer their subscription-usage endpoints; those newer routes are fixture-only (see below). Two
|
|
13
|
+
emulators are not HTTP at all: fixture-driven fake backends for the generic `EmailClient` port and
|
|
14
|
+
for the host R2 ports (`R2Presigner`, `R2ObjectClient`).
|
|
15
|
+
Emulators never import other `@yolk-sdk/*` code: their wire shapes follow conformance fixtures
|
|
16
|
+
(verified recordings for the Gateway, synthetic placeholders elsewhere), and each emulated route
|
|
17
|
+
names the conformance cases behind it. The Fortnox emulator is a stateful stand-in for the Fortnox
|
|
18
|
+
`/3` API that reproduces the observed quirks the Fortnox conformance cases claim, the Microsoft
|
|
19
|
+
Graph emulator is a stateful stand-in for the Outlook, calendar, and OneDrive routes the Microsoft
|
|
20
|
+
conformance cases use. The Dropbox, Notion, Todoist, Telegram, GitHub, Google, LinkedIn search, and
|
|
21
|
+
MCP emulators are stateful, fixture-only stand-ins for the Dropbox RPC and upload routes, the Notion
|
|
22
|
+
`/v1` routes, the Todoist API v1 routes, the Telegram Bot API routes, the GitHub REST routes, the
|
|
23
|
+
Gmail, Calendar, and Drive routes, the Exa and Enrich Layer routes, and the two synthetic MCP
|
|
24
|
+
servers their conformance cases use: they answer only what the fixtures show and refuse everything
|
|
25
|
+
else with a 400 not-emulated.
|
|
26
|
+
|
|
27
|
+
Canary APIs are unstable. Keep all `@yolk-sdk/*` packages on the same version.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm add -D @yolk-sdk/emulators@canary effect@4.0.0-rc.115
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Subpaths
|
|
36
|
+
|
|
37
|
+
There is no root export. Import an explicit subpath:
|
|
38
|
+
|
|
39
|
+
| Subpath | Purpose |
|
|
40
|
+
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| `@yolk-sdk/emulators/router` | `EmulatorRoute`, `EmulatedHttpClient.layer`, `InProcessHttpClient.layer` (Effect; no Node builtins) |
|
|
42
|
+
| `@yolk-sdk/emulators/gateway` | `makeGatewayEmulator`, `gatewayEmulatorRoutes`, `gatewayEvaluateEmulatorRoutes`, fault and scripted-turn schemas (plain fetch handler) |
|
|
43
|
+
| `@yolk-sdk/emulators/openai` | `makeOpenAiEmulator`, `openAiEmulatorRoutes`, fault and scripted-turn schemas (plain fetch handler) |
|
|
44
|
+
| `@yolk-sdk/emulators/anthropic` | `makeAnthropicEmulator`, `anthropicEmulatorRoutes`, fault and scripted-turn schemas (plain fetch handler) |
|
|
45
|
+
| `@yolk-sdk/emulators/codex` | `makeCodexEmulator`, `codexEmulatorRoutes`, fault and scripted-turn schemas (ChatGPT Codex Responses) |
|
|
46
|
+
| `@yolk-sdk/emulators/xai` | `makeXAiGrokEmulator`, `xAiGrokEmulatorRoutes`, fault and scripted-turn schemas (Grok CLI proxy Responses) |
|
|
47
|
+
| `@yolk-sdk/emulators/opencode` | `makeOpenCodeGoEmulator`, `openCodeGoEmulatorRoutes` (OpenCode Go chat, Messages, Responses, and usage) |
|
|
48
|
+
| `@yolk-sdk/emulators/email` | `makeEmailEmulator`, `emailEmulatorRoutes`, seed and fault schemas (plain-JSON `EmailClient` backend) |
|
|
49
|
+
| `@yolk-sdk/emulators/r2` | `makeR2Emulator`, `r2EmulatorRoutes`, seed and fault schemas (plain-JSON R2 port backend) |
|
|
50
|
+
| `@yolk-sdk/emulators/node` | `serveFetchHandler` (scoped Effect) and `startFetchHandlerServer` (Promise): serve a handler on `127.0.0.1` |
|
|
51
|
+
| `@yolk-sdk/emulators/fortnox` | `makeFortnoxEmulator`, `fortnoxEmulatorRoutes`, `fortnoxEmulatorQuirks`, seed and fault schemas (Node only) |
|
|
52
|
+
| `@yolk-sdk/emulators/microsoft` | `makeMicrosoftEmulator`, `microsoftEmulatorRoutes`, seed and fault schemas (Node only) |
|
|
53
|
+
| `@yolk-sdk/emulators/dropbox` | `makeDropboxEmulator`, `dropboxEmulatorRoutes`, seed and fault schemas (Node only) |
|
|
54
|
+
| `@yolk-sdk/emulators/notion` | `makeNotionEmulator`, `notionEmulatorRoutes`, seed and fault schemas (Node only) |
|
|
55
|
+
| `@yolk-sdk/emulators/todoist` | `makeTodoistEmulator`, `todoistEmulatorRoutes`, seed and fault schemas (Node only) |
|
|
56
|
+
| `@yolk-sdk/emulators/telegram` | `makeTelegramEmulator`, `telegramEmulatorRoutes`, seed and fault schemas (Node only) |
|
|
57
|
+
| `@yolk-sdk/emulators/github` | `makeGithubEmulator`, `githubEmulatorRoutes`, seed and fault schemas (Node only) |
|
|
58
|
+
| `@yolk-sdk/emulators/google` | `makeGoogleEmulator`, `googleEmulatorRoutes`, seed and fault schemas (Gmail, Calendar, Drive; Node only) |
|
|
59
|
+
| `@yolk-sdk/emulators/linkedin-search` | `makeLinkedInSearchEmulator`, `linkedInSearchEmulatorRoutes`, seed and fault schemas (Exa, Enrich Layer; Node only) |
|
|
60
|
+
| `@yolk-sdk/emulators/mcp` | `makeMcpEmulator`, `mcpEmulatorRoutes`, seed and fault schemas (synthetic MCP servers; Node only) |
|
|
61
|
+
|
|
62
|
+
## Routing
|
|
63
|
+
|
|
64
|
+
Pick a transport by swapping `HttpClient` layers. Code under test keeps calling the real origin.
|
|
65
|
+
|
|
66
|
+
| Transport | Layer | What happens |
|
|
67
|
+
| --------- | ----------------------------------------- | ------------------------------------------------------------------------ |
|
|
68
|
+
| Replay | `@yolk-sdk/conformance/replay` | Recorded fixtures, offline |
|
|
69
|
+
| InProcess | `InProcessHttpClient.layer(routes)` | Calls the emulator's fetch handler directly (no sockets) |
|
|
70
|
+
| Emulated | `EmulatedHttpClient.layer(routes)` | Rewrites the origin to a loopback emulator process, over your own client |
|
|
71
|
+
| Live | your own `HttpClient` (for example Fetch) | The real service |
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { Effect, Layer } from 'effect'
|
|
75
|
+
import { FetchHttpClient } from 'effect/unstable/http'
|
|
76
|
+
import { makeGatewayEmulator } from '@yolk-sdk/emulators/gateway'
|
|
77
|
+
import { serveFetchHandler } from '@yolk-sdk/emulators/node'
|
|
78
|
+
import { EmulatedHttpClient, EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
79
|
+
|
|
80
|
+
const gateway = makeGatewayEmulator()
|
|
81
|
+
|
|
82
|
+
// In-process: no sockets.
|
|
83
|
+
const inProcess = InProcessHttpClient.layer([
|
|
84
|
+
EmulatorRoute.handler('https://ai-gateway.vercel.sh', gateway.fetch)
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
// Emulated: a real loopback server under the host's FetchHttpClient.
|
|
88
|
+
const emulated = Layer.unwrap(
|
|
89
|
+
serveFetchHandler(gateway.fetch).pipe(
|
|
90
|
+
Effect.map(server =>
|
|
91
|
+
EmulatedHttpClient.layer([
|
|
92
|
+
EmulatorRoute.url('https://ai-gateway.vercel.sh', server.url)
|
|
93
|
+
]).pipe(Layer.provide(FetchHttpClient.layer))
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Both layers:
|
|
100
|
+
|
|
101
|
+
- keep the request path and query, and fail closed on any origin without a route with an
|
|
102
|
+
`HttpClientError` whose message names only the origin;
|
|
103
|
+
- reject malformed or duplicate origins at build time (`EmulatorRouteInvalid`);
|
|
104
|
+
- refuse to build when `NODE_ENV` is `production` or cannot be read (`EmulatorEnvironmentRefused`);
|
|
105
|
+
a missing `NODE_ENV` is allowed.
|
|
106
|
+
|
|
107
|
+
`EmulatedHttpClient` also requires every `baseUrl` to be `http(s)` on loopback (`127.0.0.0/8`,
|
|
108
|
+
`::1`, or `localhost`; IPv4-mapped forms such as `[::ffff:127.0.0.1]` are rejected) and needs your
|
|
109
|
+
real `HttpClient` underneath. `InProcessHttpClient` sends `Empty`, `Uint8Array`, and string `Raw`
|
|
110
|
+
bodies; other body kinds fail with an `EncodeError`.
|
|
111
|
+
|
|
112
|
+
Redirects never leave the route table. `EmulatedHttpClient` checks and rewrites each request at the
|
|
113
|
+
send step, so redirect follow-ups (`HttpClient.followRedirects` on top) and requests changed by
|
|
114
|
+
your own `HttpClient.mapRequest` are routed or fail closed too. With `FetchHttpClient` underneath,
|
|
115
|
+
routed requests are sent with `redirect: 'manual'`, so a 3xx comes back to the caller as a 3xx.
|
|
116
|
+
Other `FetchHttpClient.RequestInit` defaults are kept when provided around the whole client stack
|
|
117
|
+
(or where the request runs); defaults provided only to `FetchHttpClient.layer` itself are replaced.
|
|
118
|
+
Put `HttpClient.followRedirects` on top of `EmulatedHttpClient`, never underneath it. **Any other
|
|
119
|
+
underlying client must not follow redirects by itself**: a redirect it follows internally never
|
|
120
|
+
passes through the route table.
|
|
121
|
+
|
|
122
|
+
## Gateway emulator
|
|
123
|
+
|
|
124
|
+
`makeGatewayEmulator(options?)` returns `{ fetch, ledger, reset, faults, script, coverage }` for
|
|
125
|
+
`POST /v1/chat/completions`. Each call has its own state. Its wire shapes follow the verified live
|
|
126
|
+
Gateway recordings (2026-09-30) in `@yolk-sdk/agent/providers/vercel/conformance`, and its route is
|
|
127
|
+
`verified`; ids, costs, and routing metadata are synthetic stand-ins.
|
|
128
|
+
|
|
129
|
+
Defaults (no script):
|
|
130
|
+
|
|
131
|
+
- `knownModels` defaults to a small synthetic-safe list including `openai/gpt-4.1-nano`,
|
|
132
|
+
`deepseek/deepseek-v3.2`, and `deepseek/deepseek-v4.1-flash`; `reasoningModels` defaults to the
|
|
133
|
+
two DeepSeek ids.
|
|
134
|
+
- `stream: true` streams `chat.completion.chunk` server-sent events as recorded: a
|
|
135
|
+
`{ role: 'assistant' }` opening delta, several text deltas, then one finish event whose `delta`
|
|
136
|
+
carries `provider_metadata` (the recorded upstream entry, `openai` for `openai/*` models or
|
|
137
|
+
`baseten` for `deepseek/*` models, then a `gateway` routing and cost entry, all synthetic) and
|
|
138
|
+
which carries `usage` (when `stream_options.include_usage` is set), `system_fingerprint`,
|
|
139
|
+
`service_tier` (`openai/*` models only), and `generationId`, followed only by `data: [DONE]`.
|
|
140
|
+
Every chunk carries `system_fingerprint`, and every choice `logprobs: null`.
|
|
141
|
+
`stream: false` returns one `chat.completion` JSON body (not covered by a recording).
|
|
142
|
+
- Events are packed several per network chunk, as the live Gateway sends them: `eventsPerChunk`
|
|
143
|
+
(default 2, a positive integer; 1 sends one event per chunk) counted from the end, so the last
|
|
144
|
+
chunk carries the finish event and `data: [DONE]` together and the first chunk may carry fewer.
|
|
145
|
+
Chunk faults count these network chunks. An invalid `eventsPerChunk` throws
|
|
146
|
+
`GatewayEmulatorInputInvalid`.
|
|
147
|
+
- A reasoning model asked for reasoning (`reasoning_effort`, or `thinking: { type: 'enabled' }`)
|
|
148
|
+
streams `delta.reasoning` with `delta.reasoning_details`
|
|
149
|
+
(`[{ type: 'reasoning.text', text, format, index }]`) before the text.
|
|
150
|
+
- A request with `tools` gets one tool call whose arguments are synthesized from the tool's JSON
|
|
151
|
+
Schema (required string properties get non-empty synthetic values), streamed as several
|
|
152
|
+
`delta.tool_calls[].function.arguments` fragments, finishing with `tool_calls`. A `tool_choice`
|
|
153
|
+
naming an offered function picks that tool (otherwise the first); `tool_choice: 'none'` answers
|
|
154
|
+
with text.
|
|
155
|
+
- An unknown model gets the recorded 404 envelope
|
|
156
|
+
`{ error: { message: "Model '<id>' not found", type: 'model_not_found', param: { modelId } } }`
|
|
157
|
+
(no `code`).
|
|
158
|
+
- A missing `Authorization: Bearer <non-empty>` header gets a 401 envelope
|
|
159
|
+
`{ error: { message, type, code } }` (synthetic, not recorded). The token is never checked or
|
|
160
|
+
stored.
|
|
161
|
+
- Unknown routes get a 404 JSON error (fail closed) and are written to the ledger.
|
|
162
|
+
|
|
163
|
+
`script.enqueue(turn)` queues a turn for the next chat request, sent exactly as given:
|
|
164
|
+
|
|
165
|
+
- a completion: `{ text?, reasoning?, reasoningField?, order?, toolCalls?, usage?, finishReason? }`
|
|
166
|
+
where each tool call is `{ name, argumentFragments }`, `usage: null` drops usage from the finish
|
|
167
|
+
event, `order: 'text-first'` sends reasoning after the text, and `reasoningField` defaults to
|
|
168
|
+
`reasoning` (with `reasoning_details`; `reasoning_content` sends the DeepSeek-native field alone);
|
|
169
|
+
- an error: `{ error: { status, body, headers? } }`.
|
|
170
|
+
|
|
171
|
+
Emulators never redirect, and every emulated response carries a body. Fault and scripted-error
|
|
172
|
+
statuses must be 200–599 without 204, 205, or any 3xx (including 304); header names must be HTTP
|
|
173
|
+
tokens, values must not contain control characters, and `location` is rejected. Invalid input
|
|
174
|
+
throws `GatewayEmulatorInputInvalid` (the control plane answers 400).
|
|
175
|
+
|
|
176
|
+
`faults.add(fault)` adds a wire fault with an optional `match: { path?, model? }` (`path` ending in
|
|
177
|
+
`*` is a prefix) and an optional `count`:
|
|
178
|
+
|
|
179
|
+
| Fault | Effect |
|
|
180
|
+
| ----------------------- | ------------------------------------------------------------------------- |
|
|
181
|
+
| `status` | Answer with a status, headers, and body (for example 429 + `retry-after`) |
|
|
182
|
+
| `error-after-chunks` | Send N body chunks, then error the body stream (a dropped connection) |
|
|
183
|
+
| `truncate-after-chunks` | Send N body chunks, then close cleanly (no `data: [DONE]`) |
|
|
184
|
+
|
|
185
|
+
Chunk faults count network chunks: with the default packing, `truncate-after-chunks` with N = 2
|
|
186
|
+
sends up to four events (four for the default plain-text response). A chunk fault that cannot
|
|
187
|
+
take effect answers 500 instead of silently doing nothing. If the emulator cannot build a planned
|
|
188
|
+
response, it answers an evidence-tagged 500, the ledger records 500 with `responseError`, and the
|
|
189
|
+
matching fault is not used up.
|
|
190
|
+
|
|
191
|
+
`ledger.entries()` records every emulated API request (control-plane requests are not recorded):
|
|
192
|
+
method, path, parsed JSON body, model, `stream`, the `max_tokens` limit (as `maxCompletionTokens`;
|
|
193
|
+
recorded, never validated), `reasoning_effort`, `thinking`, tool names, the fault applied, the route's evidence tag, the status actually sent, and the body chunks handed over
|
|
194
|
+
so far. Credential headers are never recorded.
|
|
195
|
+
|
|
196
|
+
Control plane (same fetch handler):
|
|
197
|
+
|
|
198
|
+
| Route | Methods |
|
|
199
|
+
| -------------------- | ---------------------------------------------------------------- |
|
|
200
|
+
| `/_emulate/ledger` | `GET`, `DELETE` |
|
|
201
|
+
| `/_emulate/faults` | `GET`, `POST`, `DELETE` (`POST` takes one fault or `{ faults }`) |
|
|
202
|
+
| `/_emulate/script` | `POST` (one turn or `{ turns }`) |
|
|
203
|
+
| `/_emulate/reset` | `POST` |
|
|
204
|
+
| `/_emulate/state` | `GET` |
|
|
205
|
+
| `/_emulate/coverage` | `GET` (the route evidence manifest with request counts) |
|
|
206
|
+
|
|
207
|
+
## OpenAI emulator
|
|
208
|
+
|
|
209
|
+
`makeOpenAiEmulator(options?)` returns the same `{ fetch, ledger, reset, faults, script, coverage }`
|
|
210
|
+
shape for OpenAI Chat Completions: `POST /v1/chat/completions`, routed from
|
|
211
|
+
`https://api.openai.com`. It shares the Gateway emulator's Chat Completions core, so tool-call
|
|
212
|
+
fragments, JSON mode, faults, scripted turns, the ledger, and the control plane behave the same.
|
|
213
|
+
What differs:
|
|
214
|
+
|
|
215
|
+
- Streaming uses the plain OpenAI framing: one event per network chunk, a
|
|
216
|
+
`{ role: 'assistant', content: '' }` opening delta, and usage in a trailing chunk with empty
|
|
217
|
+
`choices`, without the Gateway's metadata fields.
|
|
218
|
+
- `knownModels` defaults to `openAiEmulatorDefaultModels` (`gpt-4.1-nano`, `gpt-4.1-mini`).
|
|
219
|
+
- Errors use the OpenAI envelope `{ error: { message, type, param, code } }`; an unknown model gets
|
|
220
|
+
404 with code `model_not_found`, and a missing `Authorization: Bearer <non-empty>` header gets
|
|
221
|
+
401 with code `invalid_api_key`. The token is never checked or stored.
|
|
222
|
+
- The ledger records `max_completion_tokens` as `maxCompletionTokens` (never validated).
|
|
223
|
+
- Reasoning models are not emulated yet: no default output streams reasoning, scripted turns
|
|
224
|
+
reject `reasoning`, `reasoningField`, and `order`, and `/_emulate/state` has no `reasoningModels`.
|
|
225
|
+
- Invalid faults or turns throw `OpenAiEmulatorInputInvalid`.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { makeOpenAiEmulator } from '@yolk-sdk/emulators/openai'
|
|
229
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
230
|
+
|
|
231
|
+
const openai = makeOpenAiEmulator()
|
|
232
|
+
|
|
233
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
234
|
+
EmulatorRoute.handler('https://api.openai.com', openai.fetch)
|
|
235
|
+
])
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`openAiEmulatorRoutes` links the route to the OpenAI chat conformance cases in
|
|
239
|
+
`@yolk-sdk/agent/providers/openai/conformance`.
|
|
240
|
+
|
|
241
|
+
## Anthropic emulator
|
|
242
|
+
|
|
243
|
+
`makeAnthropicEmulator(options?)` returns the same `{ fetch, ledger, reset, faults, script,
|
|
244
|
+
coverage }` shape for Anthropic Messages: `POST /v1/messages`, routed from
|
|
245
|
+
`https://api.anthropic.com`. Faults, the ledger, the control plane, evidence tagging, and route
|
|
246
|
+
binding are shared with the Chat Completions emulators; the Messages wire is its own:
|
|
247
|
+
|
|
248
|
+
- `stream: true` streams the Messages events in API order (block order and `ping` placement are
|
|
249
|
+
emulator choices until a live recording): `message_start` (with input
|
|
250
|
+
usage), then per content block `content_block_start`, its deltas, and `content_block_stop` (a
|
|
251
|
+
`ping` follows the first block start), then `message_delta` (`stop_reason`, and usage: input
|
|
252
|
+
and cache counts next to the cumulative `output_tokens`, as the unverified fixtures record it)
|
|
253
|
+
and `message_stop`. `stream: false` returns one `message` JSON body.
|
|
254
|
+
- `thinking: { type: 'enabled' | 'adaptive' }` adds a `thinking` block (`thinking_delta` events,
|
|
255
|
+
then one `signature_delta`) before the answer.
|
|
256
|
+
- A request with `tools` gets one `tool_use` block whose input is synthesized from the tool's
|
|
257
|
+
`input_schema` and streamed as `input_json_delta` fragments (the first one empty), stopping with
|
|
258
|
+
`tool_use`. `tool_choice: { type: 'tool', name }` picks that tool (otherwise the first);
|
|
259
|
+
`tool_choice: { type: 'none' }` answers with text. `thinking` together with a forced
|
|
260
|
+
`tool_choice` (`tool` or `any`) gets 400 `invalid_request_error`.
|
|
261
|
+
- An answer that would not fit `max_tokens` (about four characters per token) is cut and stops
|
|
262
|
+
with `max_tokens`. A missing or non-positive `max_tokens` gets 400 `invalid_request_error`.
|
|
263
|
+
- Errors use `{ type: 'error', error: { type, message } }`; an unknown model gets 404
|
|
264
|
+
`not_found_error`.
|
|
265
|
+
- Authentication accepts a non-empty `x-api-key` (native API keys) or `Authorization: Bearer`
|
|
266
|
+
(Claude OAuth); anything else gets 401 `authentication_error`. Neither value is checked or
|
|
267
|
+
stored. The ledger records which header carried it (`credentialHeader`), `anthropic-version`,
|
|
268
|
+
`anthropic-beta`, `max_tokens` (`maxTokens`), `thinking`, `tool_choice`, and tool names.
|
|
269
|
+
- `anthropic-version` must be `2023-06-01` (the value the SDK providers send by default); a missing or
|
|
270
|
+
other value gets 400 `invalid_request_error`.
|
|
271
|
+
- Not enforced: the OAuth `anthropic-beta` header for bearer credentials, and `budget_tokens`
|
|
272
|
+
limits.
|
|
273
|
+
- `knownModels` defaults to `anthropicEmulatorDefaultModels` (`claude-haiku-4-5`,
|
|
274
|
+
`claude-sonnet-4-5`). Invalid faults or turns throw `AnthropicEmulatorInputInvalid`.
|
|
275
|
+
|
|
276
|
+
`script.enqueue(turn)` queues a message `{ thinking?, text?, toolUses?, order?, usage?,
|
|
277
|
+
stopReason? }` (each tool use is `{ name, inputFragments, id? }`; a block is sent only when its
|
|
278
|
+
field is present; `usage: null` drops usage; `order: 'text-first'` sends thinking after the text;
|
|
279
|
+
`stopReason` defaults to `tool_use` with tool uses, else `end_turn`) or an error
|
|
280
|
+
`{ error: { status, body, headers? } }`.
|
|
281
|
+
|
|
282
|
+
`faults.add(fault)` takes the shared `status`, `error-after-chunks`, and `truncate-after-chunks`
|
|
283
|
+
kinds (a `status` fault's default body is the Anthropic envelope for the status, for example
|
|
284
|
+
`rate_limit_error` for 429 and `overloaded_error` for 529), plus `error-event-after-chunks`: send N
|
|
285
|
+
events, then one `event: error` (default `overloaded_error`, or `error: { type, message }`) and
|
|
286
|
+
close without `message_stop`. It applies to streamed responses only and must come before
|
|
287
|
+
`message_stop`; otherwise the request answers 500 and the fault is kept.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
import { makeAnthropicEmulator } from '@yolk-sdk/emulators/anthropic'
|
|
291
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
292
|
+
|
|
293
|
+
const anthropic = makeAnthropicEmulator()
|
|
294
|
+
|
|
295
|
+
anthropic.faults.add({ kind: 'status', status: 529, count: 1 })
|
|
296
|
+
|
|
297
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
298
|
+
EmulatorRoute.handler('https://api.anthropic.com', anthropic.fetch)
|
|
299
|
+
])
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`anthropicEmulatorRoutes` links the route to the Anthropic Messages conformance cases in
|
|
303
|
+
`@yolk-sdk/agent/providers/anthropic/conformance`.
|
|
304
|
+
|
|
305
|
+
## Responses emulators (Codex, xAI Grok)
|
|
306
|
+
|
|
307
|
+
`makeCodexEmulator(options?)` (`@yolk-sdk/emulators/codex`) and `makeXAiGrokEmulator(options?)`
|
|
308
|
+
(`@yolk-sdk/emulators/xai`) return the same `{ fetch, ledger, reset, faults, script, coverage }`
|
|
309
|
+
shape for the OpenAI Responses wire the subscription providers use:
|
|
310
|
+
|
|
311
|
+
| Subpath | Origin and route | Default models |
|
|
312
|
+
| -------- | ---------------------------------------------------------- | ------------------------ |
|
|
313
|
+
| `/codex` | `https://chatgpt.com`, `POST /backend-api/codex/responses` | `gpt-5.4`, `gpt-5.5` |
|
|
314
|
+
| `/xai` | `https://cli-chat-proxy.grok.com`, `POST /v1/responses` | `grok-build`, `grok-4.6` |
|
|
315
|
+
|
|
316
|
+
Both share one internal Responses core on the emulator kernel, so faults, the ledger, the control
|
|
317
|
+
plane, evidence tagging, and route binding behave as for the other emulators. The Responses wire:
|
|
318
|
+
|
|
319
|
+
- Requests carry `model`, an `input` string or array (400 without one), `instructions`, `tools`,
|
|
320
|
+
`tool_choice`, `reasoning`, `stream`, `store`, and `max_output_tokens`.
|
|
321
|
+
- `stream: true` streams server-sent events with typed `event:` names and a `sequence_number`, in
|
|
322
|
+
the API's order: `response.created`, `response.in_progress`, then per output item
|
|
323
|
+
`response.output_item.added`, its parts and deltas, and `response.output_item.done`, then
|
|
324
|
+
`response.completed` with the full `response` (output items and `usage`). `stream: false`
|
|
325
|
+
returns one completed `response` JSON body.
|
|
326
|
+
- A request whose `reasoning` asks for a `summary` gets a `reasoning` item first
|
|
327
|
+
(`response.reasoning_summary_part.added`, `response.reasoning_summary_text.delta` / `.done`,
|
|
328
|
+
`response.reasoning_summary_part.done`). Answers are a `message` item
|
|
329
|
+
(`response.content_part.added`, `response.output_text.delta` / `.done`,
|
|
330
|
+
`response.content_part.done`).
|
|
331
|
+
- A request with function `tools` gets one `function_call` item whose arguments are synthesized
|
|
332
|
+
from the tool's JSON Schema and streamed as `response.function_call_arguments.delta` fragments,
|
|
333
|
+
then `response.function_call_arguments.done`. `tool_choice: { type: 'function', name }` picks
|
|
334
|
+
that tool (otherwise the first); `tool_choice: 'none'` answers with text.
|
|
335
|
+
- Errors use the envelope `{ error: { message, type, param, code } }`; an unknown model gets 400
|
|
336
|
+
`model_not_found`, and a request without `Authorization: Bearer <non-empty>` gets 401
|
|
337
|
+
`invalid_api_key`. The bearer is never checked or stored.
|
|
338
|
+
- `/codex`: `max_output_tokens` gets 400 `unsupported_parameter` (the Codex endpoint takes no output
|
|
339
|
+
limit); the ledger records the `originator` header. `ChatGPT-Account-Id` is neither required nor
|
|
340
|
+
recorded.
|
|
341
|
+
- `/xai`: after the bearer, a missing `X-XAI-Token-Auth` gets 401, a missing
|
|
342
|
+
`x-grok-client-version` gets 426 (the proxy version-gates requests), and a missing
|
|
343
|
+
`x-grok-model-override` gets 400. The ledger records the client version and model override, never
|
|
344
|
+
the token-auth value. `max_output_tokens` must be a positive integer (or 400) and is recorded as
|
|
345
|
+
`maxOutputTokens`, not enforced.
|
|
346
|
+
- Not enforced (unverified leniency): `store: false`, `stream: true`, `instructions`, that the model
|
|
347
|
+
override matches `model`, the client version value, and output limits.
|
|
348
|
+
|
|
349
|
+
`script.enqueue(turn)` queues a response `{ reasoning?, text?, functionCalls?, order?, usage?,
|
|
350
|
+
format? }` (each function call is `{ name, argumentFragments, callId? }`; an item is sent only when
|
|
351
|
+
its field is present; `usage: null` drops usage from `response.completed`; `order: 'text-first'`
|
|
352
|
+
sends reasoning after the text; `format: 'json'` answers a JSON body even for `stream: true`, as
|
|
353
|
+
the JSON fallback the providers accept) or an error `{ error: { status, body, headers? } }`.
|
|
354
|
+
|
|
355
|
+
`faults.add(fault)` takes the shared `status`, `error-after-chunks`, and `truncate-after-chunks`
|
|
356
|
+
kinds (for example 429 with `retry-after`, or truncation before `response.completed`), plus
|
|
357
|
+
`error-event-after-chunks`: send N events, then one `error` event (default) or, with
|
|
358
|
+
`event: 'response.failed'`, a `response.failed` event, carrying `error: { code, message }`
|
|
359
|
+
(default `server_error`), and close without `response.completed`. It applies to streamed responses
|
|
360
|
+
only and must come before `response.completed`; otherwise the request answers 500 and the fault is
|
|
361
|
+
kept. Invalid faults or turns throw `CodexEmulatorInputInvalid` / `XAiGrokEmulatorInputInvalid`.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { makeXAiGrokEmulator } from '@yolk-sdk/emulators/xai'
|
|
365
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
366
|
+
|
|
367
|
+
const grok = makeXAiGrokEmulator()
|
|
368
|
+
|
|
369
|
+
grok.faults.add({ kind: 'truncate-after-chunks', chunks: 5, count: 1 })
|
|
370
|
+
|
|
371
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
372
|
+
EmulatorRoute.handler('https://cli-chat-proxy.grok.com', grok.fetch)
|
|
373
|
+
])
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`codexEmulatorRoutes` and `xAiGrokEmulatorRoutes` link each route to the Responses conformance
|
|
377
|
+
cases in `@yolk-sdk/agent/providers/openai/conformance` (Codex) and
|
|
378
|
+
`@yolk-sdk/agent/providers/xai/conformance` (Grok).
|
|
379
|
+
|
|
380
|
+
## Fixture-only routes
|
|
381
|
+
|
|
382
|
+
The OpenCode Go routes, the Claude, Codex, and Grok subscription-usage routes, and the Gateway
|
|
383
|
+
classifier route follow a stricter rule than the earlier model routes (whose behaviour above is unchanged): response behaviour comes
|
|
384
|
+
only from the committed conformance fixtures.
|
|
385
|
+
|
|
386
|
+
- A request that matches a recorded request's shape, within the request-shape latitude below,
|
|
387
|
+
gets that fixture's recorded response, copied as data: the same status, headers, chunks, and
|
|
388
|
+
bytes.
|
|
389
|
+
- Everything else answers one 400 not-emulated, written to the ledger (`notEmulated`) and using up
|
|
390
|
+
no fault or turn: `{ error: { type: 'not_emulated', message: 'Not emulated: <reason>' } }`. That
|
|
391
|
+
covers unknown routes and methods, missing or invalid credentials, missing or other headers and
|
|
392
|
+
query parameters, unknown models, non-streamed modes, tools, reasoning, extra fields, and
|
|
393
|
+
anything else no fixture records. No provider status, envelope, or error code is guessed.
|
|
394
|
+
- Test controls: the shared faults (`status`, `error-after-chunks`, `truncate-after-chunks`; a
|
|
395
|
+
`status` fault without a body answers `{ error: { type: 'emulator_fault', message } }`) and
|
|
396
|
+
scripted error turns `{ error: { status, body, headers? } }`. Usage routes also take a scripted
|
|
397
|
+
`{ usage }` body and a `subscriptionUsage` option, both required to keep the recorded JSON shape
|
|
398
|
+
(the same keys and value kinds; only values change).
|
|
399
|
+
- Request-shape latitude (the only accepted deviations): any credential value (never checked or
|
|
400
|
+
stored); extra request headers; JSON key order; any string value except the discriminators
|
|
401
|
+
`model`, `role`, `type`, and `phase`; any positive integer where the recording has a number (the
|
|
402
|
+
output-token limit); an `anthropic-beta` list that includes `oauth-2025-04-20` (Claude usage);
|
|
403
|
+
any non-empty `x-userid` and `x-grok-client-version` (Grok usage); `content-type` parameters. Object keys, array lengths, booleans (`stream`, `store`,
|
|
404
|
+
`include_usage`, `parallel_tool_calls`, `additionalProperties`), `accept`, the query string (byte
|
|
405
|
+
for byte; a bare `?` counts as no query), the method, `X-XAI-Token-Auth: xai-grok-cli`, and `x-grok-client-mode: headless` must
|
|
406
|
+
equal the recording or the SDK's fixed value. Faults and scripted errors on these routes take
|
|
407
|
+
statuses of 400-599 only.
|
|
408
|
+
|
|
409
|
+
All fixtures behind these routes are synthetic and the routes are `unverified`.
|
|
410
|
+
|
|
411
|
+
## Gateway classifier route
|
|
412
|
+
|
|
413
|
+
The Gateway emulator also answers AI Gateway's classifier route `POST /v1/evaluate` (Gateway calls
|
|
414
|
+
classification "evaluation"), fixture-only, from the four synthetic classifier fixtures in
|
|
415
|
+
`@yolk-sdk/agent/providers/vercel/conformance` (boolean, choice, score, and the unknown-model
|
|
416
|
+
`{ message, error_type }` envelope). A request with a non-empty bearer credential, the recorded
|
|
417
|
+
`accept` and `content-type`, no query, and a body matching one recording (the `model` and question
|
|
418
|
+
`type` exact; any other string; the recorded question ids, option keys, and level count) gets
|
|
419
|
+
that recording's response; anything else (another model, `providerOptions`, other questions)
|
|
420
|
+
answers 400 not-emulated. The route keeps its own manifest (`gatewayEvaluateEmulatorRoutes`,
|
|
421
|
+
`unverified`), ledger, faults, scripted errors, and coverage (`emulator.evaluate`, control plane
|
|
422
|
+
`/_emulate/evaluate/*`); the chat route's manifest, coverage, and top-level APIs are unchanged, and
|
|
423
|
+
`reset()` and `POST /_emulate/reset` reset both.
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import { makeGatewayEmulator } from '@yolk-sdk/emulators/gateway'
|
|
427
|
+
|
|
428
|
+
const gateway = makeGatewayEmulator()
|
|
429
|
+
|
|
430
|
+
gateway.evaluate.faults.add({
|
|
431
|
+
kind: 'status',
|
|
432
|
+
status: 429,
|
|
433
|
+
headers: { 'retry-after': '2' },
|
|
434
|
+
count: 1
|
|
435
|
+
})
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## OpenCode Go emulator
|
|
439
|
+
|
|
440
|
+
`makeOpenCodeGoEmulator(options?)` (`@yolk-sdk/emulators/opencode`) answers the origin
|
|
441
|
+
`https://opencode.ai` with one fetch handler for the routes the OpenCode Go provider and usage
|
|
442
|
+
fetcher call under `/zen/go/v1`. Each route is fixture-only and answers its Go conformance fixture:
|
|
443
|
+
|
|
444
|
+
| Route | Headers the provider sends | Recorded answer |
|
|
445
|
+
| ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------- |
|
|
446
|
+
| `POST /zen/go/v1/chat/completions` | `Authorization: Bearer` | Streamed plain text with a usage chunk and `data: [DONE]` |
|
|
447
|
+
| `POST /zen/go/v1/messages` | `x-api-key`, `anthropic-version: 2023-06-01` | Streamed plain text ending with `message_stop` |
|
|
448
|
+
| `POST /zen/go/v1/responses` | `Authorization: Bearer` | Streamed plain text, or (replayed tool turn) a streamed text answer |
|
|
449
|
+
| `GET /zen/go/v1/usage` | `Authorization: Bearer` | `usage.rolling` / `weekly` / `monthly` as `{ percent, resetsAt }` |
|
|
450
|
+
|
|
451
|
+
Only the recorded models are emulated (`openCodeGoEmulatorDefaultModels`: `synthetic-go-chat`,
|
|
452
|
+
`synthetic-go-messages`, `synthetic-go-responses`, one per protocol). It returns
|
|
453
|
+
`{ fetch, reset, coverage, chat, messages, responses, usage }`: each part is a full emulator API
|
|
454
|
+
(`ledger`, `faults`, `script`, `coverage`, its own `fetch`), and over HTTP its control plane is
|
|
455
|
+
`/_emulate/<chat|messages|responses|usage>/*`. `coverage()` and `GET /_emulate/coverage` combine
|
|
456
|
+
all four routes; `reset()` and `POST /_emulate/reset` reset every part. Requests on no route
|
|
457
|
+
answer 400 not-emulated, ledgered by the chat part. `openCodeGoUsageDefault` is the recorded usage
|
|
458
|
+
body; `options.subscriptionUsage` replaces it with a same-shaped body. Invalid input throws
|
|
459
|
+
`OpenCodeGoEmulatorInputInvalid`.
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { makeOpenCodeGoEmulator } from '@yolk-sdk/emulators/opencode'
|
|
463
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
464
|
+
|
|
465
|
+
const go = makeOpenCodeGoEmulator()
|
|
466
|
+
|
|
467
|
+
go.responses.faults.add({ kind: 'status', status: 429, headers: { 'retry-after': '2' }, count: 1 })
|
|
468
|
+
|
|
469
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
470
|
+
EmulatorRoute.handler('https://opencode.ai', go.fetch)
|
|
471
|
+
])
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
`openCodeGoEmulatorRoutes` links the four routes to the cases in
|
|
475
|
+
`@yolk-sdk/agent/providers/opencode/conformance`.
|
|
476
|
+
|
|
477
|
+
## Subscription-usage routes
|
|
478
|
+
|
|
479
|
+
The router takes one route per origin, so the usage endpoint of each subscription provider is
|
|
480
|
+
served by the emulator already bound to its origin, with its own manifest, ledger, faults, turns,
|
|
481
|
+
and coverage (`emulator.usage`, control plane `/_emulate/usage/*`). The model route's manifest,
|
|
482
|
+
coverage, and top-level `ledger` / `faults` / `script` are unchanged; the emulator types gain
|
|
483
|
+
`usage` and a `subscriptionUsage` option, and `reset()` and `POST /_emulate/reset` reset both.
|
|
484
|
+
Each usage route is fixture-only: a request with the headers the SDK fetcher sends, the recorded
|
|
485
|
+
`accept: application/json`, and the recorded query gets the recorded body (`*SubscriptionUsageDefault`);
|
|
486
|
+
anything else answers 400 not-emulated. Credential and account values are never recorded, and
|
|
487
|
+
are not checked except Grok's fixed `X-XAI-Token-Auth: xai-grok-cli`.
|
|
488
|
+
|
|
489
|
+
| Emulator | Route | Headers the fetcher sends (all required) | Recorded body |
|
|
490
|
+
| ------------ | -------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
|
491
|
+
| `/anthropic` | `GET /api/oauth/usage` | Bearer, `anthropic-beta` listing `oauth-2025-04-20` | `five_hour`, `seven_day` as `{ utilization, resets_at }` |
|
|
492
|
+
| `/codex` | `GET /backend-api/wham/usage` | Bearer, `ChatGPT-Account-Id` | `rate_limit.primary_window` / `secondary_window` |
|
|
493
|
+
| `/xai` | `GET /v1/billing?format=credits` | Bearer, `X-XAI-Token-Auth`, `x-userid`, `x-grok-client-version`, `x-grok-client-mode` | `config.creditUsagePercent` and `config.currentPeriod` |
|
|
494
|
+
| `/opencode` | `GET /zen/go/v1/usage` | Bearer | `usage.rolling` / `weekly` / `monthly` |
|
|
495
|
+
|
|
496
|
+
The ledger records `anthropic-beta` (Claude) and `x-grok-client-version` / `x-grok-client-mode`
|
|
497
|
+
(Grok), never `ChatGPT-Account-Id` or `x-userid`. The manifests
|
|
498
|
+
`anthropicSubscriptionUsageEmulatorRoutes`, `codexSubscriptionUsageEmulatorRoutes`,
|
|
499
|
+
`xAiGrokSubscriptionUsageEmulatorRoutes`, and the usage route of `openCodeGoEmulatorRoutes` cite
|
|
500
|
+
the usage snapshot cases of each vendor's conformance subpath.
|
|
501
|
+
|
|
502
|
+
## Email emulator
|
|
503
|
+
|
|
504
|
+
`makeEmailEmulator({ seed? })` is an in-memory fake backend for the generic `EmailClient` port of
|
|
505
|
+
`@yolk-sdk/connectors/email`. Yolk never speaks IMAP, POP3, or SMTP, and neither does this
|
|
506
|
+
emulator: there is no socket, TLS, MIME, or mail library, no Node builtin, and no SDK import. It is
|
|
507
|
+
a plain object whose `call(method, request)` takes one port call as plain JSON (the request without
|
|
508
|
+
credential fields) and answers `{ response }`, `{ failure }`, or `{ notEmulated: { reason } }`.
|
|
509
|
+
`emailClientLayerFromBackend(emulator)` from `@yolk-sdk/connectors/email/conformance` turns it
|
|
510
|
+
into the `EmailClient` layer, so the email conformance cases and your own tests run the real
|
|
511
|
+
connector actions against it:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
import { Layer } from 'effect'
|
|
515
|
+
import { emailClientLayerFromBackend } from '@yolk-sdk/connectors/email/conformance'
|
|
516
|
+
import { makeEmailEmulator } from '@yolk-sdk/emulators/email'
|
|
517
|
+
|
|
518
|
+
const email = makeEmailEmulator()
|
|
519
|
+
|
|
520
|
+
email.faults.add({
|
|
521
|
+
kind: 'failure',
|
|
522
|
+
method: 'move',
|
|
523
|
+
count: 1,
|
|
524
|
+
failure: { kind: 'error', code: 'transport_failed', message: 'Synthetic outage.' }
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
const emailLayer = emailClientLayerFromBackend(email)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Responses come only from the email conformance fixtures (copied as data; `emailEmulatorFixtures`).
|
|
531
|
+
The emulator keeps a mailbox (`emailEmulatorDefaultSeed`: folders with `\Drafts`, `\Sent`, and
|
|
532
|
+
`\Trash` SPECIAL-USE attributes and two INBOX messages) that only decides which fixture answers:
|
|
533
|
+
the first fixture whose method and request match and whose answer is consistent with the mailbox
|
|
534
|
+
(preferring one not used since the last reset). The mailbox then records what that fixture says
|
|
535
|
+
happened: flags set, a draft appended, a message moved to the destination id the fixture names, a
|
|
536
|
+
message deleted, a Sent copy saved. It never invents an id, flag, or response.
|
|
537
|
+
|
|
538
|
+
- Request-shape latitude: credential fields are never compared or recorded, and `connection.host`
|
|
539
|
+
is not compared, so a different practice host still matches. Every other connection field
|
|
540
|
+
(`protocol`, `port`, `security`) and everything else must equal a fixture request exactly.
|
|
541
|
+
|
|
542
|
+
Anything else fails closed with a ledgered `notEmulated` answer (the port analogue of HTTP 400):
|
|
543
|
+
an unknown method (`unknown-method`), a request that is not an object (`invalid-request`), no
|
|
544
|
+
matching fixture (`no-matching-fixture`), or no matching fixture consistent with the mailbox
|
|
545
|
+
(`state-conflict`). Faults (`kind: 'failure'`, a `method`, an optional deep-subset `match` on the
|
|
546
|
+
request, an optional `count`, and the `failure` to answer) change no state. `ledger` records every
|
|
547
|
+
call (credential-free request, outcome, fixture or fault id, reason), `state()` returns the current
|
|
548
|
+
mailbox, `reset()` restores the seed and clears the ledger, faults, and fixture use, and
|
|
549
|
+
`coverage()` reports calls per route, refusals, and unused fixtures. An invalid seed or fault
|
|
550
|
+
throws `EmailEmulatorInputInvalid`.
|
|
551
|
+
|
|
552
|
+
`emailEmulatorRoutes` names each emulated method as `PORT EmailClient.<method>` with the email
|
|
553
|
+
cases it follows. All routes are unverified: the fixtures are synthetic. Live verification needs a
|
|
554
|
+
host `EmailClient` implementation connected to a practice mailbox.
|
|
555
|
+
|
|
556
|
+
## R2 emulator
|
|
557
|
+
|
|
558
|
+
`makeR2Emulator({ seed? })` is an in-memory fake backend for the host R2 ports of
|
|
559
|
+
`@yolk-sdk/connectors/r2-storage`: `R2Presigner` (a presigned PUT URL) and `R2ObjectClient`
|
|
560
|
+
(conditional get and put). There is no SigV4 signer, S3 client, socket, Node builtin, or SDK import:
|
|
561
|
+
it is a plain object whose `call(port, method, request)` takes one port call as plain JSON (the
|
|
562
|
+
request without credential fields; bytes as base64) and answers `{ response }`, `{ failure }`, or
|
|
563
|
+
`{ notEmulated: { reason } }`. `r2PortsLayerFromBackend(emulator)` from
|
|
564
|
+
`@yolk-sdk/connectors/r2-storage/conformance` turns it into both port layers, so the R2 conformance
|
|
565
|
+
cases and your own tests run the real connector action and object helpers against it:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
import { r2PortsLayerFromBackend } from '@yolk-sdk/connectors/r2-storage/conformance'
|
|
569
|
+
import { makeR2Emulator } from '@yolk-sdk/emulators/r2'
|
|
570
|
+
|
|
571
|
+
const r2 = makeR2Emulator()
|
|
572
|
+
|
|
573
|
+
r2.faults.add({
|
|
574
|
+
kind: 'failure',
|
|
575
|
+
port: 'R2ObjectClient',
|
|
576
|
+
method: 'put',
|
|
577
|
+
count: 1,
|
|
578
|
+
failure: { kind: 'error', code: 'transport_failed', message: 'Synthetic outage.' }
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
const r2Layer = r2PortsLayerFromBackend(r2)
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Responses come only from the R2 conformance fixtures (copied as data; `r2EmulatorFixtures`). The
|
|
585
|
+
emulator keeps a bucket (`r2EmulatorDefaultSeed`: the practice bucket with the one object the get
|
|
586
|
+
fixtures read) that only decides which fixture answers: the first fixture whose port, method, and
|
|
587
|
+
request match and whose answer is consistent with the bucket (preferring one not used since the
|
|
588
|
+
last reset). The bucket then records what that fixture says happened: an object created
|
|
589
|
+
(absent-only) or replaced (under the current etag), with the etag the fixture names. It never
|
|
590
|
+
invents an etag, a byte, or a failure, and presigning writes nothing. The connector cannot delete R2
|
|
591
|
+
objects, so objects the write cases create stay; running the write cases again on the same emulator
|
|
592
|
+
fails them without writing, as a reused run id does against a live bucket.
|
|
593
|
+
|
|
594
|
+
Credentials never reach the ledger or any other output. Every credential field (`credential(s)`,
|
|
595
|
+
`accessKeyId`, `secretAccessKey`, `sessionToken`, `token`, and the other names the shared port scan
|
|
596
|
+
classifies as credentials) is dropped at any depth before anything is compared or recorded. Every
|
|
597
|
+
refusal is ledgered with constant text only (request `<redacted>`), uses no fault, and changes no
|
|
598
|
+
state; only a request equal to a fixture request is recorded, with every `bodyBase64` as
|
|
599
|
+
`<redacted>` plus its decoded length (`bodyBytes`). Every `bodyBase64` (put bytes) must be canonical
|
|
600
|
+
standard base64 of UTF-8 text (else `uncheckable-body`), and its decoded text is checked like the
|
|
601
|
+
rest of the request. A request is refused as `credential-in-request` when any key, string value,
|
|
602
|
+
number (as printed or as its digit string), or decoded body repeats a value found under a credential
|
|
603
|
+
field (any non-empty key, string, or number, with no minimum length), or holds, outside the exact
|
|
604
|
+
canonical synthetic placeholders, `X-Amz-Credential`, `X-Amz-Signature`, `X-Amz-Security-Token`, a
|
|
605
|
+
credential query parameter, or a token the shared scan flags (a bearer token, a common API-key
|
|
606
|
+
prefix, a JSON Web Token, a PEM private key). Each text is checked raw, within three rounds of
|
|
607
|
+
percent-decoding and three of escape-decoding (`\uXXXX`, `\xXX`, and numeric HTML references, as the
|
|
608
|
+
R2 conformance guard decodes), and through any depth of percent-encoding and JSON escaping, failing
|
|
609
|
+
closed past a work cap. A request with an own `__proto__` key at any depth is refused as
|
|
610
|
+
`invalid-request`, and one the checks cannot walk (cyclic, or nested too deeply) as
|
|
611
|
+
`uncheckable-request`: `call` never throws. The copied key, parameter, and token lists have one test
|
|
612
|
+
sample each, counted against the list and checked against the shared scan. A presign answer is the
|
|
613
|
+
fixture's URL, which carries only those placeholders.
|
|
614
|
+
|
|
615
|
+
- Request-shape latitude: credential fields are never compared or recorded, and JSON key order is
|
|
616
|
+
not compared. Everything else (the endpoint, bucket, key, content type, `maxBytes`,
|
|
617
|
+
`expectedEtag`, the put `condition`, `bodyBase64`, and `maxUploadBytes`) must equal a fixture
|
|
618
|
+
request exactly, so only the fixtures' `run-synthetic` run id is emulated. A `bodyBase64` must be
|
|
619
|
+
canonical standard base64 of UTF-8 text (else `uncheckable-body`); it is compared as sent but
|
|
620
|
+
recorded only as its decoded length. No object may have an own `__proto__` key (else
|
|
621
|
+
`invalid-request`), and no key or value, the decoded body included, may carry a credential or
|
|
622
|
+
repeat a dropped credential value of any length (else `credential-in-request`). Every refusal is
|
|
623
|
+
ledgered with constant text only (request `<redacted>`).
|
|
624
|
+
|
|
625
|
+
Anything else fails closed with a ledgered `notEmulated` answer (the port analogue of HTTP 400),
|
|
626
|
+
with constant text only: a port and method outside the manifest (`unknown-method`; ledgered as
|
|
627
|
+
`<unrecognised>`), a request that is not an object or has an own `__proto__` key
|
|
628
|
+
(`invalid-request`), a body that cannot be checked (`uncheckable-body`), a credential outside the
|
|
629
|
+
credential fields (`credential-in-request`), a request the checks cannot walk
|
|
630
|
+
(`uncheckable-request`; ledgered as `<unrecognised>`), no matching fixture (`no-matching-fixture`),
|
|
631
|
+
or no matching fixture consistent with the bucket (`state-conflict`). None uses a fault or changes
|
|
632
|
+
the bucket. Faults (`kind: 'failure'`, a `port` and `method`, an optional deep-subset `match` on the
|
|
633
|
+
request, an optional `count`, and the `failure` to answer) apply only to a call a fixture would
|
|
634
|
+
answer and change no state. `ledger` records every call (the fixture request of an answered or
|
|
635
|
+
faulted call, `<redacted>` for a refusal; outcome, fixture or fault id, reason), `state()` returns
|
|
636
|
+
the current bucket, `reset()` restores the seed and clears the ledger, faults, and fixture use, and
|
|
637
|
+
`coverage()` reports calls per route, refusals, and unused fixtures. An invalid seed or fault throws
|
|
638
|
+
`R2EmulatorInputInvalid`.
|
|
639
|
+
|
|
640
|
+
`r2EmulatorRoutes` names each emulated method as `PORT <Port>.<method>` with the R2 cases it
|
|
641
|
+
follows: `R2Presigner.presignPutObject`, `R2ObjectClient.get`, and `R2ObjectClient.put` (the only
|
|
642
|
+
write). All routes are unverified: the fixtures are synthetic. Live verification needs a host
|
|
643
|
+
implementation of both ports connected to a practice bucket.
|
|
644
|
+
|
|
645
|
+
## Fortnox emulator
|
|
646
|
+
|
|
647
|
+
> **Node only.** `@yolk-sdk/emulators/fortnox` runs on the upstream
|
|
648
|
+
> [`@emulators/core`](https://github.com/vercel-labs/emulate) custom runtime (Apache-2.0, pinned to
|
|
649
|
+
> exactly `0.12.0`), which imports Node builtins. The core is loaded lazily by
|
|
650
|
+
> `makeFortnoxEmulator`, so importing the subpath has no side effects.
|
|
651
|
+
|
|
652
|
+
`await makeFortnoxEmulator(options?)` returns
|
|
653
|
+
`{ fetch, baseUrl, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
|
|
654
|
+
state; `await close()` when done. Serve `fetch` in-process (`InProcessHttpClient`) or on loopback
|
|
655
|
+
(`serveFetchHandler`) and route `https://api.fortnox.se` to it; the Fortnox connector runs
|
|
656
|
+
unchanged.
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
import { Layer } from 'effect'
|
|
660
|
+
import { makeFortnoxEmulator } from '@yolk-sdk/emulators/fortnox'
|
|
661
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
662
|
+
|
|
663
|
+
const fortnox = await makeFortnoxEmulator()
|
|
664
|
+
|
|
665
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
666
|
+
EmulatorRoute.handler('https://api.fortnox.se', fortnox.fetch)
|
|
667
|
+
])
|
|
668
|
+
// ...run the code under test, then:
|
|
669
|
+
await fortnox.close()
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Routes (base path `/3`, JSON bodies, `Authorization: Bearer <non-empty>`; a missing bearer gets a
|
|
673
|
+
401 `ErrorInformation`, and the token is never stored, forwarded, or ledgered):
|
|
674
|
+
|
|
675
|
+
| Route | Behavior |
|
|
676
|
+
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
|
|
677
|
+
| `GET /3/companyinformation` | `{ CompanyInformation }` |
|
|
678
|
+
| `GET /3/customers` | Search (`name`, `email`, `city`, ...), `filter` (`active`/`inactive`), `page`/`limit`, `MetaInformation` |
|
|
679
|
+
| `GET`, `PUT /3/customers/{CustomerNumber}` | `{ Customer }` envelope |
|
|
680
|
+
| `GET /3/invoices` | Filters `unbooked`, `unpaid`, `unpaidoverdue`, `fullypaid`, `cancelled`; search; `fromdate`/`todate`; `page`/`limit` |
|
|
681
|
+
| `GET`, `PUT /3/invoices/{DocumentNumber}` | `{ Invoice }` envelope with rows |
|
|
682
|
+
| `POST /3/invoices` | Create (201); an unknown `CustomerNumber` gets 400 `ErrorInformation` code `2000433` |
|
|
683
|
+
| `GET /3/invoices/{DocumentNumber}/preview` | A small synthetic PDF (`application/pdf`); does not mark the invoice sent |
|
|
684
|
+
| `GET /3/invoices/{DocumentNumber}/email` | Marks the invoice `Sent` and records an outbox entry in state; never sends anything |
|
|
685
|
+
|
|
686
|
+
`GET /3/companyinformation` and `GET /3/customers` have no recorded fixture: they cite no
|
|
687
|
+
conformance case ids, use minimal shapes named after the connector's read fields, and are
|
|
688
|
+
unverified, uncited read routes (the evidence check warns about them).
|
|
689
|
+
|
|
690
|
+
Anything else fails closed with a 404 `ErrorInformation` (ledgered); unsupported query parameters
|
|
691
|
+
(checked per route before it runs, so a rejected write writes nothing), unknown filters, unknown or
|
|
692
|
+
read-only body fields, and values the emulated company does not have (non-SEK currency, including
|
|
693
|
+
the currency a new invoice inherits from its customer; cost centers) get a 400 `ErrorInformation`
|
|
694
|
+
instead of being ignored. Customer categorical values are limited to the emulated subset (not
|
|
695
|
+
Fortnox's full enums): `VATType` `SEVAT`, `Type` `COMPANY` or `PRIVATE`, and `TermsOfPayment` as
|
|
696
|
+
whole days from `0` to `365`. Other values (named terms such as `K`, export or reverse-charge VAT)
|
|
697
|
+
get a 400 on a customer update, and a new invoice is rejected with a 400 before anything is
|
|
698
|
+
written when the customer it inherits from (a seed can hold anything) carries a `VATType` or
|
|
699
|
+
`TermsOfPayment` outside the subset, or when its
|
|
700
|
+
computed due date is not a representable `YYYY-MM-DD` date. An empty string still keeps the
|
|
701
|
+
stored value. The list filter
|
|
702
|
+
`lastmodified` (the connector's `lastModified` input) is not emulated: the emulator tracks no
|
|
703
|
+
modification times and answers it with a 400 saying so. Errors use the lowercase `{ ErrorInformation: { error, message, code } }` of the rejection
|
|
704
|
+
fixture; `fortnoxEmulatorErrorCodes` lists the codes (the `2999xxx` ones are synthetic).
|
|
705
|
+
|
|
706
|
+
Observed quirks (`fortnoxEmulatorQuirks`, each tied to its conformance case):
|
|
707
|
+
|
|
708
|
+
1. **Row discount sticky** (`fortnox.invoice.row-discount-sticky`): `InvoiceRows` replaces the rows;
|
|
709
|
+
rows without `RowId` match existing rows by position; a matched row that omits
|
|
710
|
+
`Discount`/`DiscountType` keeps them; `Discount: 0` clears. RowIds are regenerated on every
|
|
711
|
+
update, and totals are recomputed (`Price × DeliveredQuantity × (1 − discount%)`, VAT 25% by
|
|
712
|
+
default, `Total` rounded to whole kronor).
|
|
713
|
+
2. **Empty string keeps value** (`fortnox.customer.empty-string-keeps-value`): a customer update with
|
|
714
|
+
`""` keeps the stored value; omitted fields keep theirs.
|
|
715
|
+
3. **Payment filters exclude unbooked** (`fortnox.invoice.payment-filters-exclude-unbooked`):
|
|
716
|
+
`unpaid`, `unpaidoverdue`, and `fullypaid` consider booked invoices only; `unpaidoverdue` needs a
|
|
717
|
+
`DueDate` before today (the injectable `now` clock, UTC).
|
|
718
|
+
4. **Rejection** (`fortnox.write.rejection-error-information`): writes for unknown customers or with
|
|
719
|
+
invalid fields answer 400 `ErrorInformation`.
|
|
720
|
+
5. **Read-only `Country`**: sending a customer `Country` answers 400 (no conformance case yet).
|
|
721
|
+
|
|
722
|
+
State and seeds: company information, customers, and invoices with rows, plus the email outbox.
|
|
723
|
+
The default seed is the synthetic fixture entities, with the same customer and document numbers as
|
|
724
|
+
`fortnoxConformanceFixtureSeeds`, so the Fortnox conformance cases run unmodified. Pass
|
|
725
|
+
`seed: { profile?, company?, customers?, invoices? }` (typed; entity lists replace the profile's)
|
|
726
|
+
with profiles `'default'`, `'empty-company'`, or `'no-booked-invoices'`. `reset()` restores the
|
|
727
|
+
current seed and clears the ledger and faults; `seed(next)` replaces the state and becomes what
|
|
728
|
+
`reset()` restores; `snapshot()` returns a deep copy of the state.
|
|
729
|
+
|
|
730
|
+
Faults (`faults.add` or `POST /_emulate/faults`): `{ kind: 'status', status, headers?, body?,
|
|
731
|
+
match?: { method?, path? }, count? }` answers matching requests the route would answer
|
|
732
|
+
successfully, instead of that answer. The route plans first: it reads the state without writing,
|
|
733
|
+
so a request it refuses (a 404 for an unknown id, a 400 for an invalid state or a query key, field,
|
|
734
|
+
or value it does not emulate) is answered by the route and uses up no fault, and a faulted request
|
|
735
|
+
writes nothing. As in the Gateway emulator, statuses without a body (1xx, 204, 205), redirects
|
|
736
|
+
(3xx), invalid header names or values, `location`, and framing headers are rejected when the fault
|
|
737
|
+
is added; a fault is used up only once its response is built, and a response that cannot be built
|
|
738
|
+
answers an evidence-tagged 500 `ErrorInformation` with `responseError` in the ledger, as does a
|
|
739
|
+
route handler that throws. For example a 429 with `retry-after: 2` reaches the connector as `fortnox_rate_limited`
|
|
740
|
+
with `retryAfterMs: 2000`. The ledger records method, path, route template, query, parsed body,
|
|
741
|
+
status, evidence, the applied fault, and any `responseError`.
|
|
742
|
+
|
|
743
|
+
Control plane: `/_emulate/ledger` (`GET`, `DELETE`), `/_emulate/faults` (`GET`, `POST`, `DELETE`),
|
|
744
|
+
`/_emulate/reset` (`POST`), `/_emulate/state` (`GET`), `/_emulate/seed` (`POST`), and
|
|
745
|
+
`/_emulate/coverage` (`GET`).
|
|
746
|
+
|
|
747
|
+
**Drill knobs (tests only).** `quirks: { stickyRowDiscount: false }`,
|
|
748
|
+
`{ emptyStringClears: true }`, and `{ paymentFiltersIncludeUnbooked: true }` each flip one observed
|
|
749
|
+
quirk to the plausible-but-wrong behavior. They exist only to prove that the matching conformance
|
|
750
|
+
case catches a disagreement (it fails with `ConformanceMismatch` while the others pass); never use
|
|
751
|
+
them to model Fortnox.
|
|
752
|
+
|
|
753
|
+
## Microsoft Graph emulator
|
|
754
|
+
|
|
755
|
+
> **Node only.** `@yolk-sdk/emulators/microsoft` runs on the same pinned `@emulators/core` runtime
|
|
756
|
+
> as the Fortnox emulator, loaded lazily by `makeMicrosoftEmulator`, so importing the subpath has
|
|
757
|
+
> no side effects.
|
|
758
|
+
|
|
759
|
+
`await makeMicrosoftEmulator(options?)` returns
|
|
760
|
+
`{ fetch, baseUrl, sharePointOrigin, ledger, faults, monitors, reset, seed, snapshot, coverage, close }`.
|
|
761
|
+
Each call has its own state; `await close()` when done. It emulates only what the eleven Microsoft
|
|
762
|
+
conformance cases need, so the Microsoft connector and the cases run unchanged against it.
|
|
763
|
+
|
|
764
|
+
Route **two origins** to the same fetch handler: Graph (`https://graph.microsoft.com`) and the
|
|
765
|
+
SharePoint host of the copy monitor URLs (`sharePointOrigin`, default
|
|
766
|
+
`https://synthetic-my.sharepoint.com`, the fixtures' host; the connector only accepts monitor URLs on
|
|
767
|
+
`*.sharepoint.com` or `api.onedrive.com`). The paths never overlap, so both origins may share one
|
|
768
|
+
loopback server:
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
import { makeMicrosoftEmulator } from '@yolk-sdk/emulators/microsoft'
|
|
772
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
773
|
+
|
|
774
|
+
const microsoft = await makeMicrosoftEmulator()
|
|
775
|
+
|
|
776
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
777
|
+
EmulatorRoute.handler('https://graph.microsoft.com', microsoft.fetch),
|
|
778
|
+
EmulatorRoute.handler(microsoft.sharePointOrigin, microsoft.fetch)
|
|
779
|
+
])
|
|
780
|
+
// With serveFetchHandler, use EmulatorRoute.url(origin, server.url) for both origins.
|
|
781
|
+
// ...run the code under test, then:
|
|
782
|
+
await microsoft.close()
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Graph routes (JSON; `Authorization: Bearer <non-empty>`, whose value is never checked, stored,
|
|
786
|
+
forwarded, or ledgered; a missing bearer gets 401 `InvalidAuthenticationToken`). Every Outlook
|
|
787
|
+
route needs `Prefer: IdType="ImmutableId"`, and every calendar route
|
|
788
|
+
`Prefer: outlook.timezone="UTC"`, as every fixture of theirs sends it:
|
|
789
|
+
|
|
790
|
+
| Route (`/v1.0` prefix) | Behavior |
|
|
791
|
+
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
792
|
+
| `GET /users/{userId}/calendars/{calendarId}/calendarView` | Events overlapping `[startDateTime, endDateTime)`, by start; `$select`, `$top` (required, ≤ 50; one page) |
|
|
793
|
+
| `POST /users/{userId}/calendars/{calendarId}/events` | 201 with the new event and its `id`; attendee-free, single-instance, UTC only |
|
|
794
|
+
| `GET`, `PATCH`, `DELETE /users/{userId}/events/{eventId}` | Read (`$select`), update `subject`, delete (204; later reads 404) |
|
|
795
|
+
| `POST /users/{userId}/events/{eventId}/cancel` | 202 with an empty body; the event is removed, so a later GET or DELETE is a 404 |
|
|
796
|
+
| `GET /users/{userId}/mailFolders/{folderId}/messages` | Folder by id; newest first; `$select`, `$top` (required, ≤ 2), `$skip`; opaque `@odata.nextLink` |
|
|
797
|
+
| `POST /users/{userId}/messages` | 201 draft in Drafts (never sent): `subject`, text `body`, `toRecipients`, the owner as `from` |
|
|
798
|
+
| `PATCH /users/{userId}/messages/{messageId}` | A draft's `subject` and `isRead` |
|
|
799
|
+
| `POST /users/{userId}/messages/{messageId}/move` | 201 with the draft moved to Deleted Items (`destinationId: "deleteditems"` only) |
|
|
800
|
+
| `GET /users/{userId}/messages/{messageId}/attachments[/{id}]` | Listing (`$select` only) includes inline ones, never `contentId`; retrieval has `contentId`, `contentBytes` |
|
|
801
|
+
| `POST /$batch` | Up to 20 `permanentDelete` subrequests of drafts that can all run (204 each); else the whole batch is 400 |
|
|
802
|
+
| `GET /drives/{driveId}/items/{itemId}` and `/children` | Item read (`$select`) and children by name (`$select`, `$top` required, ≤ 200; one page) |
|
|
803
|
+
| `POST /drives/{driveId}/items/{itemId}/children` | 201 folder with a free name; `@microsoft.graph.conflictBehavior` `fail` only |
|
|
804
|
+
| `DELETE /drives/{driveId}/items/{itemId}` | 204; the item and its subtree are removed (no recycle bin), later reads 404 |
|
|
805
|
+
| `POST /drives/{driveId}/items/{itemId}/copy` | A file to `parentReference { driveId, id }` on the drive, same name; 202 with one monitor `Location` |
|
|
806
|
+
|
|
807
|
+
The copy monitor, `GET /personal/{site}/_api/v2.0/monitor/{monitorId}` on the SharePoint origin,
|
|
808
|
+
needs no credential (like the real capability URL). Its first poll runs the copy and answers
|
|
809
|
+
`completed` (200) with the new item's `resourceId`, as the copy fixture records;
|
|
810
|
+
`copyInProgressPolls` (default 0) adds `inProgress` (202) answers before that. Monitors are runtime
|
|
811
|
+
data like the ledger: not part of `snapshot()`, listed by `monitors()` and `/_emulate/state`, and
|
|
812
|
+
cleared by `reset` and `seed`.
|
|
813
|
+
|
|
814
|
+
Wire behavior the cases claim:
|
|
815
|
+
|
|
816
|
+
- **Ids.** A moved message keeps its immutable id (`Prefer: IdType="ImmutableId"`), and a later
|
|
817
|
+
update by that id applies.
|
|
818
|
+
- **Times.** Event `dateTime` values are UTC with seven fractional digits
|
|
819
|
+
(`2026-09-23T12:00:00.0000000`) and `timeZone: "UTC"`; calendar reads answer
|
|
820
|
+
`preference-applied: outlook.timezone="UTC"`. A calendar request without that preference, or
|
|
821
|
+
with any other time zone, is refused.
|
|
822
|
+
- **Paging.** Folder message listings page with `$top`/`$skip`; their `@odata.nextLink` is the
|
|
823
|
+
configured `baseUrl`, the request's raw path (so `/users/ada%40example.test` keeps its `%40`),
|
|
824
|
+
and `%24select`/`%24top`/`%24skip`, byte for byte as the paging fixture. Calendar views and
|
|
825
|
+
children listings answer one page of at most `$top` (their fixtures never page), and attachment
|
|
826
|
+
listings every attachment (their fixtures send only `$select`).
|
|
827
|
+
- **Concurrent writes.** Of two overlapping writes to one message, one gets 409
|
|
828
|
+
`ErrorIrresolvableConflict` and changes nothing.
|
|
829
|
+
- **Envelopes.** Responses carry the fixtures' `@odata.context` (for example
|
|
830
|
+
`$metadata#users('ada%40example.test')/messages/$entity`), entity fields, and error envelopes;
|
|
831
|
+
`test/microsoft.test.ts` replays every fixture and compares each complete response, normalizing
|
|
832
|
+
only emulator-generated values (change keys and etags, draft conversation and internet message
|
|
833
|
+
ids, created ids, and `innerError` request ids and dates).
|
|
834
|
+
|
|
835
|
+
Emulator extrapolations (no fixture). This list predates the fixture-only rule as now stated
|
|
836
|
+
(`AGENTS.md`): it is legacy, to be removed, and never a precedent for another emulator or route. The
|
|
837
|
+
cases need each of these to run, except request-shape latitude (accepted request variations; no
|
|
838
|
+
invented wire behaviour) and the last, which is opt-in and off by default:
|
|
839
|
+
|
|
840
|
+
- **Concurrency window.** The first committed write (update or move) holds the message for
|
|
841
|
+
`conflictWindowMs` (default 25; a refused or faulted write holds nothing); an overlapping write
|
|
842
|
+
gets 409; non-overlapping writes both apply (the immutable-id case moves, then updates, the same
|
|
843
|
+
message).
|
|
844
|
+
- **Id counters.** Created ids (events, drafts, drive items) and change keys come from counters
|
|
845
|
+
that only advance, so a reversible case ends at the seed except the counters.
|
|
846
|
+
- **Removal.** A deleted or cancelled event, a permanently deleted draft, and a deleted folder
|
|
847
|
+
(with its subtree, such as the copy case's folder holding the copied file) are removed from the
|
|
848
|
+
state, so a reversible case ends at the seed.
|
|
849
|
+
- **Seed values.** Entities no fixture shows (the inbox, the attachment message itself, the drive
|
|
850
|
+
root and `Sources` folder) are synthesized; `hasAttachments` is answered as seeded (`false` for
|
|
851
|
+
new drafts), never derived from the attachments.
|
|
852
|
+
- **Request-shape latitude.** Requests that vary harmlessly from the fixtures' are answered like
|
|
853
|
+
them: `$select` may be omitted or name any fields the emulator renders, in any order; `$top` may
|
|
854
|
+
be below the fixture value (1 to 2, 50, or 200) and `$skip` any offset on folder messages; the
|
|
855
|
+
user segment may be the user's id, mail, or user principal name, case-insensitive; write bodies
|
|
856
|
+
may send any subset of the fixture keys (for example a draft with only `subject`), any `showAs`
|
|
857
|
+
free/busy status, either boolean for `isRead` and `isReminderOn`, and non-empty `toRecipients`
|
|
858
|
+
with or without names; and `calendarView` accepts any valid range, including UTC offsets. A
|
|
859
|
+
missing message or attachment answers 404 `ErrorItemNotFound`, the documented Graph code, which
|
|
860
|
+
no fixture records for them.
|
|
861
|
+
- **In-progress copies (opt-in).** `copyInProgressPolls` (default 0, so no case sees it) makes the
|
|
862
|
+
monitor answer `{ "@odata.context", "percentageComplete": 0, "status": "inProgress" }` (202)
|
|
863
|
+
that many times before the copy runs; no fixture records an in-progress poll.
|
|
864
|
+
|
|
865
|
+
Anything else fails closed with the Graph error envelope `{ error: { code, message, innerError } }`
|
|
866
|
+
(`innerError` holds a synthetic `date`, `request-id`, and `client-request-id`): unknown routes and
|
|
867
|
+
methods (including `/me` paths) get 404 `SyntheticRouteNotEmulated` and are ledgered; query keys a
|
|
868
|
+
route does not emulate get 400 before the route runs (so a rejected write writes nothing), as do
|
|
869
|
+
`$select` fields, body properties (including unknown keys inside `body`, recipients,
|
|
870
|
+
`emailAddress`, `start`/`end`, and `parentReference`), and values it does not emulate. That covers
|
|
871
|
+
Outlook requests without the immutable-id preference, `If-Match` conditional requests, conflict
|
|
872
|
+
behaviors other than `fail` (or none), name conflicts, HTML bodies, attendees, non-UTC times,
|
|
873
|
+
send-as `from`, calendar requests without `Prefer: outlook.timezone="UTC"`, collection listings
|
|
874
|
+
without `$top` or with `$top` above the largest value a fixture sends (2 for folder messages, 50
|
|
875
|
+
for calendar views, 200 for children), calendar views and children listings with more results than
|
|
876
|
+
`$top`, `$skip` anywhere but folder messages, folder message listings by well-known name or an
|
|
877
|
+
unknown folder id, moves to any destination but `deleteditems` (including `inbox`, `drafts`, and
|
|
878
|
+
folder ids), updates, moves, and permanent deletes of messages that are not drafts, permanently
|
|
879
|
+
deleting a message with attachments, copies of folders, with a new `name`, without
|
|
880
|
+
`parentReference.driveId`, or to another drive, file creation, a `$batch` subrequest that could
|
|
881
|
+
not answer 204, and a copy that can no longer run when its monitor is polled (400 at the monitor;
|
|
882
|
+
no fixture records a failed copy). A route handler that throws answers a 500 Graph error
|
|
883
|
+
envelope, recorded in the ledger with `responseError` (if the injected clock throws too, its
|
|
884
|
+
`innerError.date` is the fixed `1970-01-01T00:00:00`; unknown routes still answer, and ledger,
|
|
885
|
+
their 404, and a closed emulator its 503, with that date). `microsoftEmulatorErrorCodes` lists the
|
|
886
|
+
codes: `ErrorItemNotFound`, `itemNotFound`, and `ErrorIrresolvableConflict` come from the
|
|
887
|
+
fixtures; `InvalidAuthenticationToken`, `ErrorInvalidUser`, and `TooManyRequests` (the default 429
|
|
888
|
+
fault body) are documented Graph codes no fixture records; the `Synthetic*` ones are emulator
|
|
889
|
+
codes.
|
|
890
|
+
|
|
891
|
+
State and seeds: the mailbox user, mail folders, messages, file attachments (inline and regular),
|
|
892
|
+
calendars and events, the drive and its items, and id counters. The default seed is the synthetic
|
|
893
|
+
fixture entities with the same ids as `microsoftConformanceFixtureSeeds` (mailbox
|
|
894
|
+
`ada@example.test`, calendar, events, attachment message, paging folder with three messages, drive,
|
|
895
|
+
parent folder, and copy source). Pass `seed: { profile?, user?, mailFolders?, messages?,
|
|
896
|
+
attachments?, calendars?, events?, drive?, driveItems? }` (entity lists replace the profile's) with
|
|
897
|
+
profiles `'default'` or `'empty'`. `reset()` restores the current seed and clears the ledger,
|
|
898
|
+
faults, and monitors; `seed(next)` replaces the state; `snapshot()` returns a deep copy.
|
|
899
|
+
|
|
900
|
+
Faults, the ledger, and the control plane mirror the Fortnox emulator: `status` faults (`match`
|
|
901
|
+
by method and raw path, `count`) answer only a request the route would answer successfully (a
|
|
902
|
+
refusal such as a 404 for an unknown id, a 400 not emulated, or a 409 conflict uses up none), a
|
|
903
|
+
faulted request writes nothing, creates no copy monitor, and holds no message, and they follow the
|
|
904
|
+
shared status and header rules; the default body is a Graph error envelope (`TooManyRequests` for
|
|
905
|
+
429, so a 429 with `retry-after: 2` reaches the connector as `microsoft_rate_limited` with
|
|
906
|
+
`retryAfterMs: 2000`). The ledger records method, raw path, route template, query
|
|
907
|
+
(credential-named keys such as `access_token`, and the conformance scan's credential query
|
|
908
|
+
parameters such as `X-Amz-Signature` and `X-Amz-Credential`, are redacted), parsed body
|
|
909
|
+
(credential-named keys at any depth, such as a `$batch` subrequest's `Authorization`, are
|
|
910
|
+
redacted), the `Prefer` header, status, evidence, the applied fault, and any `responseError`.
|
|
911
|
+
Control plane: `/_emulate/ledger`, `faults`, `reset`, `state`, `seed`, and `coverage`.
|
|
912
|
+
|
|
913
|
+
**Drill knobs (tests only).** `drills: { calendarRangeEmpty: true }` (empty calendar views),
|
|
914
|
+
`{ createOmitsId: true }` (event create answers without `id`), `{ timestampPrecisionDigits: 3 }`,
|
|
915
|
+
and `{ omitNextLink: true }` each make the emulator disagree with one claim, only to prove the
|
|
916
|
+
matching conformance case catches it. The timestamp and paging knobs fail only their case; an empty
|
|
917
|
+
view also fails the timestamp case's precondition, and an id-less create also fails the cancel
|
|
918
|
+
case, which creates its event the same way.
|
|
919
|
+
|
|
920
|
+
## Dropbox emulator
|
|
921
|
+
|
|
922
|
+
> **Node only.** `@yolk-sdk/emulators/dropbox` runs on the same pinned `@emulators/core` runtime as
|
|
923
|
+
> the Fortnox and Microsoft emulators, loaded lazily by `makeDropboxEmulator`, so importing the
|
|
924
|
+
> subpath has no side effects.
|
|
925
|
+
|
|
926
|
+
`await makeDropboxEmulator(options?)` returns
|
|
927
|
+
`{ fetch, fetchOn, ledger, faults, cursors, reset, seed, snapshot, coverage, close }`. Each call has
|
|
928
|
+
its own state; `await close()` when done. It emulates only what the eight Dropbox conformance
|
|
929
|
+
cases (with their cleanup) send, so the Dropbox connector actions, the `createDropboxFile` /
|
|
930
|
+
`updateDropboxFile` upload helpers, and the cases run unchanged against it. The read-only leftover
|
|
931
|
+
lookup (`findDropboxConformanceLeftovers`) fails against the default seed: it lists the empty work
|
|
932
|
+
folder, and no fixture records a listing of an empty folder, so that listing answers the ledgered
|
|
933
|
+
400 not-emulated (nothing is written) and the lookup fails with `DropboxConformanceActionFailed`
|
|
934
|
+
(`dropbox_list_folder_failed`, HTTP 400); the live runner turns that into its lookup-failed `WARN`.
|
|
935
|
+
It lists a work folder that holds entries. Each route answers only on the origin its fixtures record: the RPC routes on
|
|
936
|
+
`https://api.dropboxapi.com` (`dropboxEmulatorApiOrigin`) and the upload on
|
|
937
|
+
`https://content.dropboxapi.com` (`dropboxEmulatorContentOrigin`). `fetch` takes the origin from the
|
|
938
|
+
request URL (in-process routing keeps it); behind a loopback rewrite, which loses it, serve
|
|
939
|
+
`fetchOn(origin)` for each origin on its own server:
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
import {
|
|
943
|
+
dropboxEmulatorApiOrigin,
|
|
944
|
+
dropboxEmulatorContentOrigin,
|
|
945
|
+
makeDropboxEmulator
|
|
946
|
+
} from '@yolk-sdk/emulators/dropbox'
|
|
947
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
948
|
+
|
|
949
|
+
const dropbox = await makeDropboxEmulator()
|
|
950
|
+
|
|
951
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
952
|
+
EmulatorRoute.handler(dropboxEmulatorApiOrigin, dropbox.fetch),
|
|
953
|
+
EmulatorRoute.handler(dropboxEmulatorContentOrigin, dropbox.fetch)
|
|
954
|
+
])
|
|
955
|
+
// With serveFetchHandler: one server per origin, serving dropbox.fetchOn(origin).
|
|
956
|
+
// ...run the code under test, then:
|
|
957
|
+
await dropbox.close()
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
Routes (every one a `POST` under `/2`, `Authorization: Bearer <non-empty>`, whose value is never
|
|
961
|
+
checked, stored, forwarded, or ledgered; RPC routes take a JSON body with no query string):
|
|
962
|
+
|
|
963
|
+
| Route | Behavior |
|
|
964
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
965
|
+
| `/2/files/list_folder` | `{ path, limit }` of a folder: `{ entries, cursor, has_more }`, at most `limit` entries per page |
|
|
966
|
+
| `/2/files/list_folder/continue` | `{ cursor }`: the next page of an unchanged listing; every page, the last included, carries a cursor |
|
|
967
|
+
| `/2/files/get_metadata` | `{ path }`: file metadata, or 409 `path/not_found`; `include_deleted: true` after a recorded delete |
|
|
968
|
+
| `/2/files/search_v2` | `{ query, options: { max_results, filename_only: true } }`: file name matches; a cursor with `has_more` |
|
|
969
|
+
| `/2/files/search/continue_v2` | `{ cursor }`: the next matches of an unchanged search (the last page has no cursor) |
|
|
970
|
+
| `/2/files/create_folder_v2` | `{ path, autorename: false }`: `{ metadata }` without `.tag`; an existing folder, any casing, is 409 |
|
|
971
|
+
| `/2/files/delete_v2` | `{ path }` (a path or `id:`) of a folder: `{ metadata }` tagged `folder`; the folder and its content go |
|
|
972
|
+
| `/2/files/copy_v2`, `move_v2` | `{ from_path, to_path, autorename: false }` of a file: `{ metadata }` (copy: new id; move: same id) |
|
|
973
|
+
| `/2/files/upload` (content host) | `Dropbox-API-Arg` `add` or `{ ".tag": "update", update: <rev> }` (by `id:`), `strict_conflict: true` |
|
|
974
|
+
|
|
975
|
+
Wire behavior, as the fixtures record it:
|
|
976
|
+
|
|
977
|
+
- **Errors.** Route errors are HTTP 409 with the fixtures' `error_summary` envelopes, byte for byte
|
|
978
|
+
(`dropboxEmulatorErrorBodies`): `path/not_found/.` for a missing path in an existing folder,
|
|
979
|
+
`path/conflict/folder/..` for an existing folder, and `path/conflict/file/..` (a `reason`
|
|
980
|
+
object) for an `add` upload onto an existing file or an `update` naming a stale rev. A rejected
|
|
981
|
+
write changes nothing.
|
|
982
|
+
- **Paths.** Lookups are case-insensitive; `path_lower` is the lower-cased path. `get_metadata`
|
|
983
|
+
answers `path_display` with the request's casing for every component but the last, which keeps
|
|
984
|
+
the stored casing, as the lower-cased lookup fixture records.
|
|
985
|
+
- **Paging.** `list_folder` pages through a folder's entries and `search_v2` through the files
|
|
986
|
+
whose names contain the query, case-insensitively, as the paging and search fixtures record.
|
|
987
|
+
- **Relocation.** `copy_v2` answers a new file with a new id and rev and the source's size, content
|
|
988
|
+
hash, and timestamps; `move_v2` keeps the id and timestamps and gets a new rev.
|
|
989
|
+
- **Uploads.** `add` never overwrites; `update` replaces the file under the same id with a new rev
|
|
990
|
+
only when its rev is current.
|
|
991
|
+
- **Deleted entries.** Deleting an empty folder keeps a deleted-entry record (state `deleted`), so
|
|
992
|
+
`get_metadata` with `include_deleted: true` answers the `deleted` metadata, as the delete fixture
|
|
993
|
+
records; afterwards `get_metadata` without it answers 409 `path/not_found`.
|
|
994
|
+
|
|
995
|
+
Every answer value comes from a fixture, through the seed or the request, except the values the
|
|
996
|
+
emulator mints (it never mints anything else):
|
|
997
|
+
|
|
998
|
+
- **Minted values.** Created ids (`id:SyntheticEntry00000001`) and revs (`a1b2c3d4e5f60101`) come
|
|
999
|
+
from counters that only advance and start above the highest seeded id and rev in that form, so a
|
|
1000
|
+
minted value never repeats a seeded one; a created file's content hash is 62 zeros and its rev's
|
|
1001
|
+
last two digits, as the upload fixture writes it; upload timestamps come from the injectable `now`
|
|
1002
|
+
clock; cursors (`AAHsyntheticListCursorNNNN`, `AAHsyntheticSearchCursorNNNN`) come from counters
|
|
1003
|
+
that never reset. A reversible case therefore ends at the seed except the counters and the
|
|
1004
|
+
deleted-entry record of an empty case folder it deleted.
|
|
1005
|
+
- **Implied folders.** The parent folders the seeded paths need (`/Conformance`,
|
|
1006
|
+
`/Conformance/Paging`, `/Conformance/Search`, `/Conformance/Work`) are `implied`: no fixture shows
|
|
1007
|
+
them, so lookups pass through them but any answer that would render one is not emulated.
|
|
1008
|
+
- **Cursors.** A cursor is accepted only when this emulator issued it since the last reset or seed
|
|
1009
|
+
(reset and seed clear the registry; cursor values are never reissued) and the listing or search
|
|
1010
|
+
it continues renders exactly as when it was issued. Cursors are runtime data, listed by
|
|
1011
|
+
`cursors()` and `/_emulate/state`.
|
|
1012
|
+
- **Request-shape latitude (`/dropbox`, the only accepted deviations).** Any bearer value (never
|
|
1013
|
+
checked or stored); extra request headers; JSON key order; `content-type` media-type parameters;
|
|
1014
|
+
any path and search query (looked up in the state); any `list_folder` `limit` from 1 to 2000 and
|
|
1015
|
+
any `search_v2` `options.max_results` from 1 to 1000; and any upload body bytes. Everything else
|
|
1016
|
+
(other keys, booleans, modes, query parameters, another origin, ids or revs where the fixtures
|
|
1017
|
+
send paths, the root folder, and cursors not issued since the last reset or whose listing
|
|
1018
|
+
changed) is not emulated.
|
|
1019
|
+
|
|
1020
|
+
Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
|
|
1021
|
+
`notEmulated` in the ledger), writes nothing, and uses up no fault: unknown routes and methods, a
|
|
1022
|
+
route on another origin, a missing bearer, query parameters, other content types, unknown or
|
|
1023
|
+
missing body keys, `autorename` or `include_deleted: false`, `strict_conflict: false`, other upload
|
|
1024
|
+
modes, the root folder, `id:` or `rev:` paths where the fixtures send paths, `get_metadata` of a
|
|
1025
|
+
folder or of a path whose parent folder is missing or a file, `include_deleted` without a recorded
|
|
1026
|
+
delete or on a live entry, listing a file, a missing folder, an empty folder, or a folder holding an
|
|
1027
|
+
implied one, a search without matches or matching a folder, a missing parent folder, deleting a file, a missing
|
|
1028
|
+
entry, or an implied folder, copying or moving a folder or onto an existing entry, an `add` upload
|
|
1029
|
+
onto a folder, an `update` upload of a missing file, and the cursors above. A route that throws (for
|
|
1030
|
+
example when the upload clock throws) answers an evidence-tagged 500 emulator error with
|
|
1031
|
+
`responseError` in the ledger, and a closed emulator answers 503.
|
|
1032
|
+
|
|
1033
|
+
State and seeds: entries (files and folders by parent id; files carry rev, size, content hash, and
|
|
1034
|
+
timestamps; `implied` marks a folder no fixture shows), deleted-entry records, and counters. The
|
|
1035
|
+
default seed is the synthetic fixture entries at the paths of `dropboxConformanceFixtureSeeds`
|
|
1036
|
+
(the paging folder with three entries, the mixed-case file, two search matches, and the copy source,
|
|
1037
|
+
under implied folders). Pass `seed: { profile?, entries?, deleted? }` (entries by display path; a
|
|
1038
|
+
parent folder must be seeded too) with profiles `'default'` or `'empty'`. `reset()` restores the
|
|
1039
|
+
current seed and clears the ledger, faults, and cursors; `seed(next)` replaces the state;
|
|
1040
|
+
`snapshot()` returns a deep copy.
|
|
1041
|
+
|
|
1042
|
+
Faults (`faults.add` or `POST /_emulate/faults`):
|
|
1043
|
+
`{ kind: 'status', status, headers?, body?, match?: { method?, path?, route? }, count? }` with a
|
|
1044
|
+
status of 400-599 answers a matching request the emulator would otherwise answer, instead of its
|
|
1045
|
+
write (nothing is written). In `match`, `method` is the HTTP method, `path` the raw request path (a
|
|
1046
|
+
trailing `*` makes it a prefix), and `route` a route template of the manifest
|
|
1047
|
+
(`dropboxEmulatorRoutes`; a value that names no manifest row is rejected when the fault is added).
|
|
1048
|
+
The route's pure, state-reading eligibility check runs first, so a request that is not emulated
|
|
1049
|
+
never uses one up. The default body is `{ error: { type: 'emulator_fault', message } }` (never a
|
|
1050
|
+
guessed Dropbox body), so a 429 with `retry-after: 2` reaches the connector as
|
|
1051
|
+
`dropbox_rate_limited` with `retryAfterMs: 2000`. The ledger records method, raw path, route
|
|
1052
|
+
template, query (credential-named keys such as `authorization` and `access_token` redacted; a value
|
|
1053
|
+
starting with `{` or `[`, such as a browser-style `arg`, parsed with credential-named keys redacted
|
|
1054
|
+
at any depth, or `<redacted>` when unparseable), the parsed JSON body (credential-named keys
|
|
1055
|
+
redacted), an upload's body length (never its bytes), the `Dropbox-API-Arg` header (parsed,
|
|
1056
|
+
credential-named keys redacted at any depth; an unparseable value is recorded as `<redacted>`),
|
|
1057
|
+
status, evidence, the applied fault, `notEmulated`, and any `responseError`. Control plane:
|
|
1058
|
+
`/_emulate/ledger`, `faults`, `reset`, `state`, `seed`, and `coverage`.
|
|
1059
|
+
|
|
1060
|
+
**Drill knobs (tests only).** `drills: { listFolderSinglePage, getMetadataCaseSensitive,
|
|
1061
|
+
searchRepeatsMatches, notFoundAsPathLookup, folderConflictAsFile, deleteLeavesNoTombstone,
|
|
1062
|
+
moveMintsNewId, uploadIgnoresRev }` (booleans) each make the emulator disagree with exactly one
|
|
1063
|
+
Dropbox case, only to prove that case catches it.
|
|
1064
|
+
|
|
1065
|
+
## Notion emulator
|
|
1066
|
+
|
|
1067
|
+
> **Node only.** `@yolk-sdk/emulators/notion` runs on the same pinned `@emulators/core` runtime,
|
|
1068
|
+
> loaded lazily by `makeNotionEmulator`, so importing the subpath has no side effects.
|
|
1069
|
+
|
|
1070
|
+
`await makeNotionEmulator(options?)` returns
|
|
1071
|
+
`{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has
|
|
1072
|
+
its own state; `await close()` when done. It emulates only what the eight Notion conformance cases
|
|
1073
|
+
(with their cleanup) send, so the Notion connector and the cases run unchanged against it. The
|
|
1074
|
+
read-only leftover lookup (`findNotionConformanceLeftovers`) fails against it whenever its search
|
|
1075
|
+
for `yolk-conformance` has no match (a clean workspace) or matches a trashed page (after the write
|
|
1076
|
+
case): no fixture records either answer, so the search answers the ledgered 400 not-emulated
|
|
1077
|
+
(nothing is written) and the lookup fails with `NotionConformanceActionFailed`
|
|
1078
|
+
(`notion_search_failed`, HTTP 400); the live runner turns that into its lookup-failed `WARN`. It
|
|
1079
|
+
answers when every match is an untrashed page with timestamps. Every route answers only on the
|
|
1080
|
+
origin its fixtures record, `https://api.notion.com` (`notionEmulatorOrigin`): `fetch` reads the
|
|
1081
|
+
origin from the request URL (in-process routing keeps it); behind a loopback rewrite, which loses
|
|
1082
|
+
it, serve `fetchOn(notionEmulatorOrigin)`. Route `https://api.notion.com` to it:
|
|
1083
|
+
|
|
1084
|
+
```ts
|
|
1085
|
+
import { makeNotionEmulator } from '@yolk-sdk/emulators/notion'
|
|
1086
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
1087
|
+
|
|
1088
|
+
const notion = await makeNotionEmulator()
|
|
1089
|
+
|
|
1090
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
1091
|
+
EmulatorRoute.handler('https://api.notion.com', notion.fetch)
|
|
1092
|
+
])
|
|
1093
|
+
// ...run the code under test, then:
|
|
1094
|
+
await notion.close()
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
Routes (under `/v1`; every request needs `Authorization: Bearer <non-empty>`, whose value is never
|
|
1098
|
+
checked, stored, forwarded, or ledgered, and `Notion-Version: 2025-09-03`, the version every fixture
|
|
1099
|
+
sends; bodies are JSON):
|
|
1100
|
+
|
|
1101
|
+
| Route | Behavior |
|
|
1102
|
+
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
1103
|
+
| `POST /v1/search` | `{ query, filter: { property: "object", value: "page" }, page_size, start_cursor? }`: pages by title |
|
|
1104
|
+
| `GET /v1/users/me` | The integration's bot user |
|
|
1105
|
+
| `GET /v1/pages/{pageId}` | The page (trashed pages too); 404 `object_not_found` or 400 `validation_error` envelopes |
|
|
1106
|
+
| `POST /v1/pages` | The recorded create: a child of a page titled `yolk-conformance page: safe to delete` |
|
|
1107
|
+
| `PATCH /v1/pages/{pageId}` | `{ archived: true }`: the page with `archived` and `in_trash` true |
|
|
1108
|
+
| `GET /v1/blocks/{blockId}/children` | `page_size` (required), `start_cursor`: a page's child blocks |
|
|
1109
|
+
| `GET /v1/pages/{pageId}/properties/{propertyId}` | `page_size` (required), `start_cursor`: the items of a paginated property |
|
|
1110
|
+
| `GET /v1/databases/{databaseId}` | The database with its `data_sources` (`{ id, name }`) |
|
|
1111
|
+
| `GET /v1/data_sources/{dataSourceId}` | The data source: `properties` schema, `parent: { type: "database_id" }`, `database_parent` |
|
|
1112
|
+
| `POST /v1/data_sources/{dataSourceId}/query` | `{ page_size: 1 }`: the recorded first row, `parent: { type: "data_source_id" }`, and the next cursor |
|
|
1113
|
+
|
|
1114
|
+
Wire behavior, as the fixtures record it:
|
|
1115
|
+
|
|
1116
|
+
- **Version.** Every route needs `Notion-Version: 2025-09-03`; any other version (or none) is not
|
|
1117
|
+
emulated.
|
|
1118
|
+
- **Cursor paging.** Lists answer `{ object: "list", results, next_cursor, has_more, type, <type>:
|
|
1119
|
+
{} }`, and the last page carries `next_cursor: null` (present, not absent). Search and block
|
|
1120
|
+
cursors are the next result's id; property item cursors are opaque, with `property_item.next_url`
|
|
1121
|
+
naming the property id as the page object returns it.
|
|
1122
|
+
- **Errors.** A page read of a well-formed id that addresses no page answers 404
|
|
1123
|
+
`{ object: "error", status: 404, code: "object_not_found", message, request_id }`; a malformed id
|
|
1124
|
+
answers 400 `validation_error`, with the fixtures' messages.
|
|
1125
|
+
- **Property ids.** The property id path segment is decoded once, so the connector's second
|
|
1126
|
+
percent-encoding (`Syn%253Ap`) names the property the page returns as `Syn%3Ap`.
|
|
1127
|
+
- **Data source split.** The database lists its data sources; the data source holds the schema and
|
|
1128
|
+
names its database; its query answers the recorded first row, whose parent names the data source,
|
|
1129
|
+
with the next row's id (which the fixture only names) as `next_cursor`.
|
|
1130
|
+
- **Archive.** `archived: true` answers the page with `archived` and `in_trash` true and keeps
|
|
1131
|
+
`last_edited_time`; the page still reads back (200) as archived.
|
|
1132
|
+
|
|
1133
|
+
Every answer value comes from a fixture, through the seed or the request, except the values the
|
|
1134
|
+
emulator mints (it never mints anything else):
|
|
1135
|
+
|
|
1136
|
+
- **Minted values.** Created page ids (`1f0000e0-0000-4000-8000-000000000001`, ...) come from a
|
|
1137
|
+
counter that only advances and starts above the highest seeded id in that form; created pages take
|
|
1138
|
+
their timestamps from the injectable `now` clock; request ids come from the ledger sequence;
|
|
1139
|
+
property item cursors come from a counter that never resets (the first is the fixture's value);
|
|
1140
|
+
search and block cursors are the next result's id (the fixture's value) only in the generation
|
|
1141
|
+
that first issued that id for that list, and `<id>.g<generation>` after a reset or seed (each
|
|
1142
|
+
starts a generation). A reversible run ends at the seed except the counter and its own page, in
|
|
1143
|
+
the trash.
|
|
1144
|
+
- **Implied pages.** Pages a fixture only names by id (the blocks page, the write case's parent page,
|
|
1145
|
+
the database's parent page, and the second data source row) are `impliedPages`: their ids resolve
|
|
1146
|
+
where a fixture names them (a block parent, a create parent, a database parent, the query's next
|
|
1147
|
+
row), but no content exists for them, so any answer that would render one is not emulated.
|
|
1148
|
+
- **Search scope.** Search considers only the pages whose content the state holds (the `pages`):
|
|
1149
|
+
an implied page has no known title, so it never matches a search. A search answers only matches
|
|
1150
|
+
in the shape the search fixture records (untrashed pages with timestamps): a match that is
|
|
1151
|
+
trashed, or shown only as a query row (no timestamps), makes the search not emulated.
|
|
1152
|
+
- **Cursors.** A cursor is accepted only when this emulator issued it for the same list (the same
|
|
1153
|
+
search query, block parent, or property) since the last reset or seed, and the list renders
|
|
1154
|
+
exactly as when it was issued. No cursor value crosses a reset or seed (see minted values), so a
|
|
1155
|
+
pre-reset cursor stays refused even after the same first-page request.
|
|
1156
|
+
- **Request-shape latitude (`/notion`, the only accepted deviations).** Any bearer value (never
|
|
1157
|
+
checked or stored); extra request headers; JSON key order; `content-type` media-type parameters;
|
|
1158
|
+
the order of query parameters; Notion ids with or without dashes, in any case; any search `query`
|
|
1159
|
+
(looked up in the state); and any `page_size` from 1 to 100 whose page shows only recorded results
|
|
1160
|
+
(the data source query: 1). `Notion-Version` must be `2025-09-03`. Everything else (other keys,
|
|
1161
|
+
filters, booleans, sorts, query parameters, another origin, titles, repeated or missing `page_size`, and cursors
|
|
1162
|
+
not issued for the same list since the last reset or whose list changed) is not emulated.
|
|
1163
|
+
|
|
1164
|
+
Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
|
|
1165
|
+
`notEmulated` in the ledger), writes nothing, and uses up no fault: unknown routes and methods (other
|
|
1166
|
+
users, comments, block reads or updates, database queries, page deletes), another origin, a missing bearer or
|
|
1167
|
+
`Notion-Version`, query parameters on routes that take none, other search filters and keys, a search
|
|
1168
|
+
without matches or whose matches include a trashed page or a page shown only as a query row, `sorts`, `filter`, or `start_cursor` on the data source query,
|
|
1169
|
+
a query page that would show a row no fixture shows (or a last page), the cursors above, children
|
|
1170
|
+
of anything but a page or of a page without recorded child blocks, a property with no seeded item list (or a singly encoded property id), a
|
|
1171
|
+
missing database or data source, a read of an implied page or of a page shown only as a query row,
|
|
1172
|
+
page creates with `children`, a database parent, another title, more than one title item,
|
|
1173
|
+
annotations, or a missing or trashed parent, page updates other than `{ archived: true }`, and
|
|
1174
|
+
archiving a missing, implied, row, or already trashed page. A route that throws answers an
|
|
1175
|
+
evidence-tagged 500 emulator error with `responseError` in the ledger, and a closed emulator answers 503.
|
|
1176
|
+
|
|
1177
|
+
State and seeds: the bot user, pages (`parent`, trash flags, `properties` as stored, `url`), implied
|
|
1178
|
+
pages, blocks, paginated property items, databases, data sources, and the page counter. The default
|
|
1179
|
+
seed is the synthetic fixture entities with the same ids as `notionConformanceFixtureSeeds`. Pass
|
|
1180
|
+
`seed: { profile?, botUser?, pages?, impliedPages?, blocks?, propertyItems?, databases?,
|
|
1181
|
+
dataSources? }` (lists replace the profile's) with profiles `'default'` or `'empty'`. Property
|
|
1182
|
+
item `next_url` values name the recorded origin. `reset()`, `seed(next)`, and
|
|
1183
|
+
`snapshot()` behave as in the Dropbox emulator; reset and seed also clear issued cursors.
|
|
1184
|
+
|
|
1185
|
+
Faults, the ledger (which records the `Notion-Version` header), and the control plane behave as in
|
|
1186
|
+
the Dropbox emulator; a 429 fault with `retry-after` reaches the connector as
|
|
1187
|
+
`notion_rate_limited`.
|
|
1188
|
+
|
|
1189
|
+
**Drill knobs (tests only).** `drills: { searchRepeatsResults, botUserAsPerson,
|
|
1190
|
+
envelopeStatusMismatch, omitTitlePlainText, blockCursorRepeats, rejectDoubleEncodedPropertyId,
|
|
1191
|
+
rowParentAsDatabase, trashedPageNotFound }` (booleans) each make the emulator disagree with exactly
|
|
1192
|
+
one Notion case, only to prove that case catches it.
|
|
1193
|
+
|
|
1194
|
+
## Todoist and Telegram emulators
|
|
1195
|
+
|
|
1196
|
+
> **Node only.** `@yolk-sdk/emulators/todoist` and `@yolk-sdk/emulators/telegram` run on the same
|
|
1197
|
+
> pinned `@emulators/core` runtime as the Fortnox and Microsoft emulators, loaded lazily by
|
|
1198
|
+
> `makeTodoistEmulator` / `makeTelegramEmulator`, so importing either subpath has no side effects.
|
|
1199
|
+
|
|
1200
|
+
Both are **fixture-only** and stateful: response behaviour comes only from their committed
|
|
1201
|
+
conformance fixtures (copied as data), the state decides which recorded answer applies (created
|
|
1202
|
+
ids, a deleted project, a sent message), and everything the fixtures do not record answers one
|
|
1203
|
+
ledgered 400 not-emulated, `{ error: { type: 'not_emulated', message: 'Not emulated: <reason>' } }`
|
|
1204
|
+
(`notEmulated` in the ledger), with no guessed provider status, envelope, or error code. That covers
|
|
1205
|
+
unknown routes and methods, missing or malformed credentials, query parameters, body fields, and
|
|
1206
|
+
values no fixture records. A refused request writes nothing and uses up no fault (eligibility is
|
|
1207
|
+
checked against the request and the state before any fault is chosen, and the check, the fault
|
|
1208
|
+
decision, and the write run together, so concurrent requests never interleave). They run on the
|
|
1209
|
+
internal stateful wrapper the Dropbox, Notion, GitHub, Google, LinkedIn search, and MCP emulators
|
|
1210
|
+
share (ledger, faults, control plane, clock-free recovery); each returns
|
|
1211
|
+
`{ fetch, ledger, faults, reset, seed, snapshot, coverage, close }` (Todoist adds `cursors`). Each
|
|
1212
|
+
call has its own state; `await close()` when done (later requests answer 503).
|
|
1213
|
+
|
|
1214
|
+
```ts
|
|
1215
|
+
import { makeTelegramEmulator } from '@yolk-sdk/emulators/telegram'
|
|
1216
|
+
import { makeTodoistEmulator } from '@yolk-sdk/emulators/todoist'
|
|
1217
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
1218
|
+
|
|
1219
|
+
const todoist = await makeTodoistEmulator()
|
|
1220
|
+
const telegram = await makeTelegramEmulator()
|
|
1221
|
+
|
|
1222
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
1223
|
+
EmulatorRoute.handler('https://api.todoist.com', todoist.fetch),
|
|
1224
|
+
EmulatorRoute.handler('https://api.telegram.org', telegram.fetch)
|
|
1225
|
+
])
|
|
1226
|
+
// ...run the code under test, then:
|
|
1227
|
+
await Promise.all([todoist.close(), telegram.close()])
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
### Todoist emulator
|
|
1231
|
+
|
|
1232
|
+
Routes (under `/api/v1`, JSON, `Authorization: Bearer <token>` with a token of at least 8
|
|
1233
|
+
characters, whose value is never checked against anything, stored, forwarded, or ledgered; without
|
|
1234
|
+
it a request is not emulated):
|
|
1235
|
+
|
|
1236
|
+
| Route | Behavior |
|
|
1237
|
+
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1238
|
+
| `GET /tasks?project_id&limit&cursor` | Active tasks by `child_order` of the paging project (`limit=2`) or a case project created here (no `limit`); REST v1 cursor paging (`{ results, next_cursor }`) |
|
|
1239
|
+
| `POST /tasks` | `{ content, project_id, due_date? }` in a case project created here: the new task (200) |
|
|
1240
|
+
| `GET /tasks/{taskId}` | An active task (labels as label names), or the fixtures' 404 `Task not found` body |
|
|
1241
|
+
| `POST /tasks/{taskId}` | `content` and/or `due_datetime` of an active task created here: the updated task |
|
|
1242
|
+
| `POST /tasks/{taskId}/close` | Closes an active task created here (204, no body); it leaves the active listing |
|
|
1243
|
+
| `GET /labels?limit` | Personal labels, one page (`next_cursor: null`) |
|
|
1244
|
+
| `POST /projects` | `{ name, parent_id }` for a case project `yolk-conformance-<runId>-<lifecycle\|due\|parent\|delete>` |
|
|
1245
|
+
| `GET /projects/{projectId}` | A project created here, or the fixtures' 404 `Project not found` body |
|
|
1246
|
+
| `DELETE /projects/{projectId}` | A project created here: 204; it and its task are removed, so later reads answer 404 |
|
|
1247
|
+
|
|
1248
|
+
Wire claims, all from the fixtures: project and task bodies carry the fixtures' keys, order, and
|
|
1249
|
+
default values (including `child_order: 1`); 404 bodies are `{ error, error_code: 478, error_extra:
|
|
1250
|
+
{ event_id }, error_tag: 'NOT_FOUND', http_code: 404 }`; the first task page of a listing larger
|
|
1251
|
+
than `limit=2` answers `next_cursor` (`SyntheticTaskCursor0001`, ...), the cursor leads to the next
|
|
1252
|
+
page of the same project and `limit`, and the last page answers `next_cursor: null`;
|
|
1253
|
+
`due_date: '2030-01-15'` and `due_datetime: '2030-01-15T12:00:00Z'` answer the recorded due
|
|
1254
|
+
objects; an update keeps `updated_at`, as recorded.
|
|
1255
|
+
|
|
1256
|
+
Nothing else is synthesised. Seeded projects (the work and paging projects, whose objects no
|
|
1257
|
+
fixture records; the fixtures name only their ids) are reference targets only: reading one answers
|
|
1258
|
+
not-emulated, and only projects and tasks created through the recorded create flow are updated,
|
|
1259
|
+
closed, or deleted. A case project takes one task and a seeded parent one sub-project, because the
|
|
1260
|
+
fixtures record `child_order: 1` only (a second one is not emulated). The labeled task is in the
|
|
1261
|
+
work project (the synthetic label fixture was corrected to match the paging listing, which never
|
|
1262
|
+
lists it).
|
|
1263
|
+
|
|
1264
|
+
**Unrecognised requests.** The emulator fails closed: a request is recognised only when its raw path
|
|
1265
|
+
is exactly one of the routes above under its HTTP method, with raw id segments that are Todoist ids
|
|
1266
|
+
(percent-encoding is never recognised). Every other request is ledgered and answered with constant
|
|
1267
|
+
text only: the path `/<unrecognised>`, a standard method or `<other>`, no query, no body, and the
|
|
1268
|
+
reason `no emulated Todoist route for this method and path`. A request whose `Authorization` header
|
|
1269
|
+
is present but is not one recognisable bearer (a non-bearer scheme, a value under 8 characters,
|
|
1270
|
+
extra words, combined duplicate headers) is ledgered the same way, whatever its route, with the
|
|
1271
|
+
reason `an unrecognisable Authorization header is not emulated`: its credential cannot be extracted
|
|
1272
|
+
and scrubbed, so nothing from the request is recorded.
|
|
1273
|
+
|
|
1274
|
+
**Sharing one emulator.** Run write cases sequentially on one emulator, or `reset()` between cases.
|
|
1275
|
+
A seeded parent takes one case project at a time (the fixtures record `child_order: 1` only), so
|
|
1276
|
+
concurrent write cases, or a case that failed before its cleanup, make later project creates answer
|
|
1277
|
+
not-emulated (a definitive rejection: nothing is created). `test/todoist-conformance.test.ts` runs
|
|
1278
|
+
all seven cases one after another on one emulator, which ends at the seed except the counters.
|
|
1279
|
+
|
|
1280
|
+
Every answer value comes from a fixture, through the seed or the request, except the values the
|
|
1281
|
+
emulator mints (it never mints anything else):
|
|
1282
|
+
|
|
1283
|
+
- **Minted values.** Created project ids (`6XEmuProject0001`), task ids (`6XEmuTask0000001`), and
|
|
1284
|
+
404 `event_id`s (`00000000000000000000000000000001`) come from counters that only advance;
|
|
1285
|
+
created timestamps come from the injectable `now` clock (default `Date.now`). Minted ids use the
|
|
1286
|
+
reserved prefix `6XEmu`, which a seed may not use (it is rejected), so seeded and created ids
|
|
1287
|
+
never collide. Cursors are runtime data (`cursors()`, `/_emulate/state`), valid only as issued
|
|
1288
|
+
since the last `reset` or `seed`.
|
|
1289
|
+
- **Removal.** A deleted project and its task are removed from the state, so a write case ends at
|
|
1290
|
+
the seed except the counters.
|
|
1291
|
+
- **Request-shape latitude** (the only accepted deviations from the fixture requests): any
|
|
1292
|
+
credential value of at least 8 characters that occurs nowhere else in the request (its path,
|
|
1293
|
+
query, or body; never checked against anything, stored, or ledgered); extra request headers;
|
|
1294
|
+
`content-type` parameters; query parameters in any order; any Todoist id (1-64 of `[A-Za-z0-9_-]`)
|
|
1295
|
+
of an existing item where a fixture has an id (reads: seeded or created tasks and created
|
|
1296
|
+
projects; task listings: the paging project with `limit=2` and case projects created here without
|
|
1297
|
+
`limit`; writes: only items created through the recorded create flow; a new project's `parent_id`:
|
|
1298
|
+
a seeded project); any `run-` run id (at most 40 characters) in a case project name; any non-empty
|
|
1299
|
+
task `content`; a task update sending `content`, `due_datetime`, or both; a label listing `limit`
|
|
1300
|
+
of 1 to 200 that covers every label.
|
|
1301
|
+
|
|
1302
|
+
Not emulated (400), among others: other routes (`/tasks/filter`, sections, comments, REST v2, and
|
|
1303
|
+
the project listing `GET /projects`); task listings without `project_id`, of any project but the
|
|
1304
|
+
paging project with `limit=2` or a case project created here without `limit`, or with a cursor the
|
|
1305
|
+
emulator did not issue since its last reset or that is sent with another `project_id` or `limit`;
|
|
1306
|
+
label listings without `limit` or with more labels than `limit` (paging them is not emulated); body
|
|
1307
|
+
fields no fixture sends (`labels`, `priority`, `due_string`, `description`, ...); other due values;
|
|
1308
|
+
project names outside the run namespace; reading a seeded project; projects under a case project or
|
|
1309
|
+
an unknown parent, or a second sub-project; tasks outside a case project created here, or a second
|
|
1310
|
+
one in it; reading a closed task; updating or closing a seeded, unknown, or closed task; deleting a
|
|
1311
|
+
seeded or unknown project; a path, query, or body that repeats the bearer value; write bodies that
|
|
1312
|
+
are not `application/json` objects. Reading an absent task or project answers the fixtures' 404.
|
|
1313
|
+
|
|
1314
|
+
**Leftover lookup.** No fixture records the project listing that `findTodoistConformanceLeftovers`
|
|
1315
|
+
sends (`GET /projects?limit=200`), so it answers the ledgered 400 not-emulated like any other
|
|
1316
|
+
unrecorded route: the lookup fails with `todoist_list_projects_failed` (HTTP 400), and the
|
|
1317
|
+
repository runners print their lookup-failed WARN (`WARN could not look for leftovers (lookup
|
|
1318
|
+
failed: todoist_list_projects_failed HTTP 400); check for yolk-conformance items by hand`) instead
|
|
1319
|
+
of leftover warnings. It never writes. Emulating the listing needs a committed fixture for it first.
|
|
1320
|
+
|
|
1321
|
+
Seeds: `seed: { profile?, userId?, projects?, tasks?, labels? }` (entity lists replace the
|
|
1322
|
+
profile's) with profiles `'default'` (the fixture entities, with the ids of
|
|
1323
|
+
`todoistConformanceFixtureSeeds`) and `'empty'` (only the work project). Ids starting with `6XEmu`
|
|
1324
|
+
are rejected.
|
|
1325
|
+
|
|
1326
|
+
**Drill knobs (tests only).** `drills: { cursorRestarts, notFoundWithoutError, taskLabelsAsIds,
|
|
1327
|
+
listIncludesClosed, ignoreDue, createOmitsParent, deleteKeepsTasks }` each make the emulator
|
|
1328
|
+
disagree with exactly one Todoist case (paging, not-found envelope, labels, lifecycle, due dates,
|
|
1329
|
+
parent id, delete), only to prove that case catches it.
|
|
1330
|
+
|
|
1331
|
+
### Telegram emulator
|
|
1332
|
+
|
|
1333
|
+
Routes (on `https://api.telegram.org`; the bot token is a path segment):
|
|
1334
|
+
|
|
1335
|
+
| Route | Behavior |
|
|
1336
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
1337
|
+
| `POST /bot<token>/getChat` | `{ chat_id }`: `{ ok: true, result }` for a member chat, else the recorded 400 `Bad Request: chat not found` |
|
|
1338
|
+
| `GET /bot<token>/getFile?file_id` | `{ ok: true, result: { file_id, file_unique_id, file_size, file_path } }` for a seeded file |
|
|
1339
|
+
| `GET /file/bot<token>/<file_path>` | The file's bytes (`application/octet-stream`), exactly `file_size` of them |
|
|
1340
|
+
| `POST /bot<token>/sendMessage` | `{ chat_id, text, disable_web_page_preview: true }`: `{ ok: true, result }` with the sent message |
|
|
1341
|
+
|
|
1342
|
+
**The bot token is required but never stored, forwarded, ledgered, or echoed.** The emulator fails
|
|
1343
|
+
closed. A request is recognised only when its raw path is exactly an emulated route shape under that
|
|
1344
|
+
route's HTTP method: `/bot<token>/<method>` with an emulated method, or
|
|
1345
|
+
`/file/bot<token>/<file_path>` with plain `[A-Za-z0-9_.-]` segments, the token on the raw segment
|
|
1346
|
+
matching `<digits>:<secret>` (a secret of at least 8 of `[A-Za-z0-9_-]`), and no other path text.
|
|
1347
|
+
Every other request (an unknown method or route, extra segments, a missing, malformed, or
|
|
1348
|
+
percent-encoded token, an encoded separator such as `%2F` or `%252F`) is ledgered and answered with
|
|
1349
|
+
constant text only: the path `/<unrecognised>`, a standard method or `<other>`, no query, no body,
|
|
1350
|
+
and the reason `no emulated Bot API route for this method and path`. For a recognised request, the
|
|
1351
|
+
token is taken from that exact segment; the emulator scrubs it and its secret part from the ledgered
|
|
1352
|
+
method, path (`/bot<redacted>/getChat`), query keys and values, and every not-emulated message, and
|
|
1353
|
+
it refuses, with constant text, a query, a remaining path segment, or a body that repeats either:
|
|
1354
|
+
raw, percent-decoded, or in any parsed JSON key, string value, or number (so `\u`-escaped forms and
|
|
1355
|
+
numbers such as `1.2345678e7` are caught). Refusal messages never quote a request key or value. A
|
|
1356
|
+
token whose bot id is `0` names no bot: `getChat` answers the recorded 401 `Unauthorized` (`{ ok:
|
|
1357
|
+
false, error_code: 401, description }`); other methods with it are not emulated. Fault `match.path`
|
|
1358
|
+
uses the redacted path; `match.route` the manifest template (`/bot{token}/sendMessage`). In both the
|
|
1359
|
+
Todoist and Telegram emulators, a `match.route` naming no manifest route is rejected when added.
|
|
1360
|
+
|
|
1361
|
+
`sendMessage` is irreversible on the real service: the emulator records each sent message in its
|
|
1362
|
+
state (`sentMessages`: `message_id` from 101, `chat_id`, `text`, `date` from the `now` clock in
|
|
1363
|
+
seconds) and never delivers anything; only `reset` or `seed` drops them. The answer's `from` is the
|
|
1364
|
+
seeded bot (never derived from the token).
|
|
1365
|
+
|
|
1366
|
+
Every answer value comes from a fixture, through the seed or the request, except the values the
|
|
1367
|
+
emulator mints (it never mints anything else): the `message_id` counter and the clock-derived
|
|
1368
|
+
`date`.
|
|
1369
|
+
|
|
1370
|
+
**Request-shape latitude** (the only accepted deviations from the fixture requests): any credential
|
|
1371
|
+
value of at least 8 characters that occurs nowhere else in the request (its path, query, or body;
|
|
1372
|
+
never checked against anything, stored, or ledgered); extra request headers; `content-type`
|
|
1373
|
+
parameters; a well-formed `<digits>:<secret>` bot token with a secret of at least 8 characters (the
|
|
1374
|
+
bot id `0` names no bot); any well-formed `chat_id` string (an integer or a public `@username`; one
|
|
1375
|
+
the bot is not in answers the recorded 400 on `getChat`); any non-empty message `text`.
|
|
1376
|
+
|
|
1377
|
+
Not emulated (400), among others: other methods (`getMe`, `deleteMessage`, ...), `GET getChat`,
|
|
1378
|
+
query parameters or body fields no fixture sends (`parse_mode`, `link_preview_options`, ...),
|
|
1379
|
+
`disable_web_page_preview` other than `true`, numeric `chat_id`s, `sendMessage` to a chat the bot is
|
|
1380
|
+
not in, `getFile` of a file the bot did not receive, and file paths `getFile` did not answer.
|
|
1381
|
+
|
|
1382
|
+
Seeds: `seed: { profile?, bot?, chats?, files?, nextMessageId? }` with profiles `'default'` (the
|
|
1383
|
+
fixture bot, chat, and 32-byte text file, with the ids of `telegramConformanceFixtureSeeds`) and
|
|
1384
|
+
`'empty'` (the bot only). A file's `file_size` must equal its UTF-8 content's byte length.
|
|
1385
|
+
|
|
1386
|
+
**Drill knobs (tests only).** `drills: { getChatOkFalse, errorsAs200, fileSizeOffByOne,
|
|
1387
|
+
sendOkFalse }` each make the emulator disagree with exactly one Telegram case.
|
|
1388
|
+
|
|
1389
|
+
### Faults, ledger, recovery, and control plane
|
|
1390
|
+
|
|
1391
|
+
Faults are `{ kind: 'status', status, headers?, body?, match?: { method?, path?, route? }, count? }`
|
|
1392
|
+
with statuses 400-599 only (fixture-only routes never fake a success). A fault is chosen only after
|
|
1393
|
+
the request passed every check, including the route handler's eligibility check against the
|
|
1394
|
+
state (which writes nothing), and it answers before the commit (nothing is written); a refused
|
|
1395
|
+
request answers 400 not-emulated and leaves every fault unused. The default body is
|
|
1396
|
+
`{ error: { type: 'emulator_fault', message } }`. For example a 429 reaches the connectors as
|
|
1397
|
+
`todoist_rate_limited` / `telegram_rate_limited`. Header rules are the shared ones (valid names and
|
|
1398
|
+
values, no `location`, no framing headers); invalid faults throw `TodoistEmulatorInputInvalid` /
|
|
1399
|
+
`TelegramEmulatorInputInvalid`. The ledger records method and path, route template, query keys
|
|
1400
|
+
and values, parsed body, status, evidence, `notEmulated`, the applied fault, and `responseError`;
|
|
1401
|
+
credential-named keys are redacted, and guarded credential values are scrubbed from every one of
|
|
1402
|
+
them (a body that holds one is refused and never recorded). An unrecognised request (fail closed,
|
|
1403
|
+
above) is ledgered with constant fields only: `/<unrecognised>`, a standard method or `<other>`,
|
|
1404
|
+
an empty query, no body, and a constant `notEmulated` reason.
|
|
1405
|
+
|
|
1406
|
+
Recovery never reads the clock: a route handler that throws (for example because the injected clock
|
|
1407
|
+
throws while creating a project or sending a message) answers an evidence-tagged 500
|
|
1408
|
+
`{ error: { type: 'emulator_error', message } }` with `responseError` in the ledger and writes
|
|
1409
|
+
nothing; not-emulated answers (400) and a closed emulator (503) need no clock. The control plane is
|
|
1410
|
+
`/_emulate/ledger`, `faults`, `reset`, `state`, `seed`, and `coverage`, as for Fortnox.
|
|
1411
|
+
|
|
1412
|
+
## GitHub emulator
|
|
1413
|
+
|
|
1414
|
+
> **Node only.** `@yolk-sdk/emulators/github` runs on the same pinned `@emulators/core` runtime,
|
|
1415
|
+
> loaded lazily by `makeGithubEmulator`, so importing the subpath has no side effects.
|
|
1416
|
+
|
|
1417
|
+
`await makeGithubEmulator(options?)` returns
|
|
1418
|
+
`{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
|
|
1419
|
+
state; `await close()` when done. It is a stateful, fixture-only stand-in for exactly the GitHub
|
|
1420
|
+
REST routes the seven GitHub conformance cases send, so the GitHub connector and the cases run
|
|
1421
|
+
unchanged against it. Every route answers only on the origin its fixtures record,
|
|
1422
|
+
`https://api.github.com` (`githubEmulatorOrigin`): `fetch` reads the origin from the request URL;
|
|
1423
|
+
behind a loopback rewrite, serve `fetchOn(githubEmulatorOrigin)`. Route `https://api.github.com` to
|
|
1424
|
+
it:
|
|
1425
|
+
|
|
1426
|
+
```ts
|
|
1427
|
+
import { makeGithubEmulator } from '@yolk-sdk/emulators/github'
|
|
1428
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
1429
|
+
|
|
1430
|
+
const github = await makeGithubEmulator()
|
|
1431
|
+
|
|
1432
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
1433
|
+
EmulatorRoute.handler('https://api.github.com', github.fetch)
|
|
1434
|
+
])
|
|
1435
|
+
// ...run the code under test, then:
|
|
1436
|
+
await github.close()
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
Routes (every request needs exactly `Authorization: Bearer <token>` with a recognisable bearer (see
|
|
1440
|
+
Credentials), whose value is never compared against anything, stored, forwarded, or ledgered,
|
|
1441
|
+
`Accept: application/vnd.github+json`, and `X-GitHub-Api-Version: 2026-03-10`, what every fixture
|
|
1442
|
+
sends; bodies are JSON; `{owner}/{repo}` is the seeded repository):
|
|
1443
|
+
|
|
1444
|
+
| Route | Behavior |
|
|
1445
|
+
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
1446
|
+
| `GET /repos/{owner}/{repo}/labels` | `per_page`, `page`: repository labels with the paging fixture's `Link` header |
|
|
1447
|
+
| `GET /repos/{owner}/{repo}/issues/{issueNumber}` | The issue; the recorded 404 for a number the repository has not reached |
|
|
1448
|
+
| `GET /search/issues` | `q`: the recorded 422 for a scoped query longer than 256 characters |
|
|
1449
|
+
| `GET /repos/{owner}/{repo}/contents/{path+}` | A seeded file: `type: "file"`, base64 `content` folded every 60 characters |
|
|
1450
|
+
| `POST /repos/{owner}/{repo}/issues/{issueNumber}/comments` | `{ body }`: 201 and the comment, on an open issue |
|
|
1451
|
+
| `GET /repos/{owner}/{repo}/issues/{issueNumber}/comments` | `per_page=100`, `since`: the issue's comments updated at or after `since` (0 or 1) |
|
|
1452
|
+
| `DELETE /repos/{owner}/{repo}/issues/comments/{commentId}` | 204; the recorded 404 when this emulator deleted it already |
|
|
1453
|
+
| `POST /repos/{owner}/{repo}/issues/{issueNumber}/labels` | `{ labels: [name] }`: one repository label added; answers the issue labels |
|
|
1454
|
+
| `DELETE /repos/{owner}/{repo}/issues/{issueNumber}/labels/{name}` | The remaining labels (never none); the recorded 404 for a label not on the issue |
|
|
1455
|
+
| `POST /repos/{owner}/{repo}/issues` | `{ title, body }`: 201 and the open issue |
|
|
1456
|
+
| `PATCH /repos/{owner}/{repo}/issues/{issueNumber}` | `{ title }` or `{ state: "closed", state_reason: "completed" }` on an issue made here |
|
|
1457
|
+
|
|
1458
|
+
Wire behavior, as the fixtures record it:
|
|
1459
|
+
|
|
1460
|
+
- **Link paging.** Label pages carry the paging fixture's `Link` header, minted in its exact form:
|
|
1461
|
+
`<https://api.github.com/repositories/{id}/labels?per_page=N&page=M>` relations in the order
|
|
1462
|
+
`prev`, `next`, `last`, `first` (`next` and `last` while pages remain, `prev` and `first` after
|
|
1463
|
+
the first page; the page after the last answers `[]` with `prev`, `last`, and `first`). A listing
|
|
1464
|
+
that fits one page carries no `Link`, as the label fixture records. No other route mints `Link`.
|
|
1465
|
+
Label pages are page numbers the client computes, as GitHub's are, not cursors: the label list
|
|
1466
|
+
never changes within a seed (no route writes labels), so no page can drift. The `Link` URLs name
|
|
1467
|
+
`/repositories/{id}/labels`, which is not emulated (the connector never follows them).
|
|
1468
|
+
- **Errors.** The not-found, comment, label, and validation bodies are the fixtures' byte for byte
|
|
1469
|
+
(`githubEmulatorErrorBodies`), with `content-type: application/json; charset=utf-8`.
|
|
1470
|
+
- **Issues.** Issues render in the fixture key order with `comments: 0`, `locked: false`, no
|
|
1471
|
+
assignees or milestone. A rename keeps `updated_at` (the lifecycle fixture records that);
|
|
1472
|
+
closing sets `state_reason: "completed"` and `closed_at` and `updated_at` from the clock.
|
|
1473
|
+
- **Contents.** `content` is the UTF-8 file's base64, folded every 60 characters with a trailing
|
|
1474
|
+
line break, and `size` its byte length.
|
|
1475
|
+
|
|
1476
|
+
Every answer value comes from a fixture, through the seed or the request, except the values the
|
|
1477
|
+
emulator mints (it never mints anything else):
|
|
1478
|
+
|
|
1479
|
+
- **Minted values.** Created issue numbers and comment ids come from counters that only advance; the
|
|
1480
|
+
default seed starts them at the fixtures' created values (issue `42`, comment `9000000001`), and a
|
|
1481
|
+
seed's counters must lie above every seeded number. They end at the last addressable issue number
|
|
1482
|
+
(ten digits) and comment id (fifteen digits, what the delete route takes); past that, a create is
|
|
1483
|
+
refused before any fault. A created issue's `id` (`3000000000 + number`) and `node_id`
|
|
1484
|
+
(`I_kwSynthetic<number>`), and a comment's `node_id` (`IC_kwSynthetic<id>`), derive from the
|
|
1485
|
+
minted value in the fixtures' form; no seeded node id (of an issue or a label; node ids are unique
|
|
1486
|
+
across both) may use those forms at or above its counter. Timestamps come from the injectable
|
|
1487
|
+
`now` clock, in whole seconds.
|
|
1488
|
+
- **Implied issues.** Issue numbers below `nextIssueNumber` that the state does not hold are
|
|
1489
|
+
implied (the repository reached them, but no fixture shows them): any answer that would render
|
|
1490
|
+
one is not emulated. Numbers at or above it answer the not-found fixture's 404.
|
|
1491
|
+
- **State rules.** Comments and label changes apply to issues the state holds and that are open;
|
|
1492
|
+
only issues created here are renamed or closed; a label add takes one repository label not yet on
|
|
1493
|
+
the issue that sorts after the issue's labels (the fixture's answer is both appended and in name
|
|
1494
|
+
order); a label removal leaves at least one label (no fixture records an empty answer); a comment
|
|
1495
|
+
listing shows at most one comment (the order of several is not recorded); an issue holding
|
|
1496
|
+
comments is never rendered (every fixture answers `comments: 0`); a comment delete of an id this
|
|
1497
|
+
emulator never held is not emulated.
|
|
1498
|
+
- **Kept after writes.** A deleted comment's id stays in `deletedComments` (the comment fixture's
|
|
1499
|
+
second delete answers 404), and a closed issue stays in the repository (GitHub cannot delete
|
|
1500
|
+
issues). A write case ends at the seed except those and the counters.
|
|
1501
|
+
- **Fail closed.** A request is recognised only when its raw path is exactly an emulated route shape
|
|
1502
|
+
(every path parameter matches its raw pattern: owner and repository names, decimal issue numbers
|
|
1503
|
+
and comment ids, plain label names, plain file paths) under that route's method, and any
|
|
1504
|
+
`Authorization` header is exactly `Bearer <token>` (see Credentials). Every other request (an
|
|
1505
|
+
unknown route or method, an encoded character, a malformed or duplicated `Authorization` header)
|
|
1506
|
+
is ledgered and answered with constant text only: the path `/<unrecognised>`, a standard method or
|
|
1507
|
+
`<other>`, an empty query, no body, and a constant reason
|
|
1508
|
+
(`no emulated GitHub route for this method and path`,
|
|
1509
|
+
`an unrecognisable Authorization header is not emulated`).
|
|
1510
|
+
- **Credentials.** A recognised bearer must match the RFC 6750 `b64token` syntax exactly
|
|
1511
|
+
(`^[A-Za-z0-9\-._~+/]+=*$`, at least 8 characters), start with a character in `[G-Zg-z\-._~+/]`
|
|
1512
|
+
other than `n`, `r`, `t`, `u`, and hold at least one character outside the JSON-number alphabet
|
|
1513
|
+
`[0-9.eE+-]` (every GitHub and Google token form does: `ghp_…`, `github_pat_…`, `gho_…`,
|
|
1514
|
+
`ya29.…`). So no number's text can contain it; it holds no escape introducer (`%`, `\`, `"`), so
|
|
1515
|
+
no escape starts inside it; and its first character is no hex digit and no JSON escape letter, so
|
|
1516
|
+
no stray `%`, `\`, or partial escape to its left can complete with it, and its characters always
|
|
1517
|
+
decode in place. An `Authorization` header with any other value is unrecognisable. A recognised
|
|
1518
|
+
request that repeats the bearer value in its raw path, any path segment, the raw query or any
|
|
1519
|
+
query key or value, any recorded header, or its body is refused and ledgered with constant text
|
|
1520
|
+
only: a standard method, the path `/<unrecognised>`, its route template, an empty query, no
|
|
1521
|
+
headers or body, and a constant reason (`the query repeats the credential`, for example). Each
|
|
1522
|
+
part is checked through the closure of two total, lexical transforms that cannot fail: a tolerant
|
|
1523
|
+
percent-decode (every `%XX` below `%80` becomes its ASCII character; any other `%` sequence is
|
|
1524
|
+
left as it is) and a tolerant JSON-unescape (in any text, whether or not it parses as JSON,
|
|
1525
|
+
`\uXXXX` below `\u0080` and `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t` become their
|
|
1526
|
+
characters). Starting from each part's raw text, either transform is applied to every text of the
|
|
1527
|
+
previous step, deduplicated, until no new text appears (a fixpoint), and every text is checked for
|
|
1528
|
+
the bearer as a substring; both transforms never lengthen a text and shorten it whenever they
|
|
1529
|
+
change it. So any depth of percent-encoding or JSON escaping, in any order, is seen through in
|
|
1530
|
+
every part: the raw path and each raw path segment, the raw query and each query key and value
|
|
1531
|
+
(already decoded once by `URLSearchParams`), each recorded header, and the raw body. The work is
|
|
1532
|
+
capped at 64 rounds, 1024 distinct texts, or 8 Mi characters read by the transforms, whichever
|
|
1533
|
+
comes first; a part whose closure hits a cap before its fixpoint counts as repeating the
|
|
1534
|
+
credential and is refused with the same constant entry (uncertainty refuses, it never admits; so
|
|
1535
|
+
any part over 4 Mi characters is always refused). Any other recognised request has the bearer
|
|
1536
|
+
value scrubbed from its ledgered fields and every not-emulated reason (plan-time reasons
|
|
1537
|
+
included); its recorded query is keyed by recorded key; a key recorded more than once lists its
|
|
1538
|
+
values in order (as a JSON array); and recorded headers and query keys and values that start like
|
|
1539
|
+
JSON (`{`, `[`, `"`) are recorded parsed with credential-named keys redacted at any depth, or as
|
|
1540
|
+
`<redacted>` when they do not parse, whatever the header's declared format. Refusals never echo a
|
|
1541
|
+
request's own query or body keys, and empty query components (a bare `?`, a stray `&`) are
|
|
1542
|
+
refused.
|
|
1543
|
+
- **Request-shape latitude (`/github`, the only accepted deviations).** Any bearer value in the RFC
|
|
1544
|
+
6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
|
|
1545
|
+
character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u` (so a legacy all-hex token is
|
|
1546
|
+
refused), with at least one outside `[0-9.eE+-]`, that occurs nowhere else in the request (never
|
|
1547
|
+
compared against anything, stored, or ledgered); extra request headers; JSON key order;
|
|
1548
|
+
`content-type` media-type parameters; the order of query parameters; any non-empty issue title and
|
|
1549
|
+
comment body, and any issue body text; any comment listing `since` of the form
|
|
1550
|
+
`YYYY-MM-DDTHH:MM:SSZ`; any label listing `per_page` from 1 to 100, with no `page` or a `page`
|
|
1551
|
+
from 2 to one past the last page; any issue search `q` that starts with the seeded
|
|
1552
|
+
`repo:<owner>/<repo>` qualifier and whose query after it is longer than 256 characters (answered
|
|
1553
|
+
the recorded 422); any issue number the repository has not reached (answered the recorded 404);
|
|
1554
|
+
and any issue, comment, repository label, or file the state holds where a fixture has one, under
|
|
1555
|
+
the per-route state rules. `Authorization` must be exactly `Bearer <token>` (that spelling, one
|
|
1556
|
+
space), `Accept` `application/vnd.github+json`, and `X-GitHub-Api-Version` `2026-03-10`.
|
|
1557
|
+
Everything else (other keys and values, query parameters, empty query components such as a bare
|
|
1558
|
+
`?` or a stray `&`, another origin or repository, a repeated query key, an explicit `page=1`, and
|
|
1559
|
+
a comment listing `per_page` other than 100) is not emulated.
|
|
1560
|
+
|
|
1561
|
+
Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
|
|
1562
|
+
`notEmulated` in the ledger), writes nothing, and uses up no fault: other routes (issue and pull
|
|
1563
|
+
request listings, locks, assignees, reactions, timelines), a search of at most 256 characters
|
|
1564
|
+
(no fixture records results), `sort`, `order`, or paging on search, a contents `ref` or a missing
|
|
1565
|
+
file, issue creates with `labels`, `assignees`, `milestone`, or `type`, updates other than the two
|
|
1566
|
+
recorded ones (so the lifecycle case's failure-path restore, which closes as `not_planned`, is not
|
|
1567
|
+
emulated), adding a label the repository lacks (that would create it) or two labels at once, and
|
|
1568
|
+
writes to closed or implied issues. That includes the open-issue listing
|
|
1569
|
+
(`GET /repos/{owner}/{repo}/issues`) of the read-only leftover lookup
|
|
1570
|
+
`findGithubConformanceLeftovers`, which no fixture records: the lookup fails
|
|
1571
|
+
(`GithubConformanceActionFailed`, `github.list_issues`, `github_validation`, HTTP 400), and the
|
|
1572
|
+
repository runner prints its lookup-failed `WARN` instead of leftover warnings. A route that throws
|
|
1573
|
+
answers an evidence-tagged 500 emulator error with `responseError` in the ledger, and a closed
|
|
1574
|
+
emulator answers 503; neither reads the clock.
|
|
1575
|
+
|
|
1576
|
+
State and seeds: the authenticated `viewer` (the author of everything created here), the
|
|
1577
|
+
`repository` (owner, name, the id the `Link` URLs name, default branch), labels, issues (labels by
|
|
1578
|
+
name; `createdHere` marks issues created here), comments, `deletedComments`, files (path, blob sha,
|
|
1579
|
+
UTF-8 text), and the counters. The default seed is the synthetic fixture entities with the values of
|
|
1580
|
+
`githubConformanceFixtureSeeds`: the five paging-fixture labels, open work issue 1 labelled `bug`,
|
|
1581
|
+
and `docs/synthetic-notes.txt`. Pass a `seed` with any of `profile`, `viewer`, `repository`,
|
|
1582
|
+
`labels`, `issues`, `files`, `nextIssueNumber`, and `nextCommentId` (lists replace the profile's)
|
|
1583
|
+
with profiles `'default'` or `'empty'`. `reset()`, `seed(next)`, and `snapshot()` behave as in the
|
|
1584
|
+
Dropbox emulator.
|
|
1585
|
+
|
|
1586
|
+
Faults, the ledger (which records the `Accept` and `X-GitHub-Api-Version` headers), and the control
|
|
1587
|
+
plane behave as in the Dropbox emulator; a 429 fault with `retry-after` reaches the connector as
|
|
1588
|
+
`github_rate_limited`.
|
|
1589
|
+
|
|
1590
|
+
**Drill knobs (tests only).** The `drills` booleans `linkOmitsNext`,
|
|
1591
|
+
`notFoundOmitsDocumentationUrl`, `validationWithoutErrors`, `contentUnfolded`, `sinceExcludesEqual`,
|
|
1592
|
+
`addAnswerOmitsLabel`, and `closeWithoutClosedAt` each make the emulator disagree with exactly one
|
|
1593
|
+
GitHub case, only to prove that case catches it.
|
|
1594
|
+
|
|
1595
|
+
## Google emulator
|
|
1596
|
+
|
|
1597
|
+
> **Node only.** `@yolk-sdk/emulators/google` runs on the same pinned `@emulators/core` runtime,
|
|
1598
|
+
> loaded lazily by `makeGoogleEmulator`, so importing the subpath has no side effects.
|
|
1599
|
+
|
|
1600
|
+
`await makeGoogleEmulator(options?)` returns
|
|
1601
|
+
`{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
|
|
1602
|
+
state; `await close()` when done. It emulates only the Gmail, Calendar, and Drive routes the
|
|
1603
|
+
thirteen Google conformance cases (with their cleanup) send, so the Google connector actions and the
|
|
1604
|
+
cases run unchanged against it, the irreversible practice send included. Each route answers only on
|
|
1605
|
+
the origin its fixtures record: Gmail (the API and the multipart send upload) on
|
|
1606
|
+
`https://gmail.googleapis.com` (`googleEmulatorGmailOrigin`), Calendar and Drive on
|
|
1607
|
+
`https://www.googleapis.com` (`googleEmulatorApisOrigin`). `fetch` takes the origin from the request
|
|
1608
|
+
URL (in-process routing keeps it); behind a loopback rewrite, which loses it, serve
|
|
1609
|
+
`fetchOn(origin)` for each origin on its own server:
|
|
1610
|
+
|
|
1611
|
+
```ts
|
|
1612
|
+
import {
|
|
1613
|
+
googleEmulatorApisOrigin,
|
|
1614
|
+
googleEmulatorGmailOrigin,
|
|
1615
|
+
makeGoogleEmulator
|
|
1616
|
+
} from '@yolk-sdk/emulators/google'
|
|
1617
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
1618
|
+
|
|
1619
|
+
const google = await makeGoogleEmulator()
|
|
1620
|
+
|
|
1621
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
1622
|
+
EmulatorRoute.handler(googleEmulatorGmailOrigin, google.fetch),
|
|
1623
|
+
EmulatorRoute.handler(googleEmulatorApisOrigin, google.fetch)
|
|
1624
|
+
])
|
|
1625
|
+
// With serveFetchHandler: one server per origin, serving google.fetchOn(origin).
|
|
1626
|
+
// ...run the code under test, then:
|
|
1627
|
+
await google.close()
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
The read-only leftover lookup (`findGoogleConformanceLeftovers`) fails against it: its first read,
|
|
1631
|
+
the Gmail label listing, has no fixture (nor do its draft search, free-text event query, and
|
|
1632
|
+
trashed-included Drive listing), so it answers the ledgered 400 not-emulated (nothing is written)
|
|
1633
|
+
and the lookup fails with `GoogleConformanceActionFailed` (`gmail_list_labels_failed`, HTTP 400);
|
|
1634
|
+
the live runner turns that into its lookup-failed `WARN`.
|
|
1635
|
+
|
|
1636
|
+
Routes (every request needs `Authorization: Bearer <token>`, a recognisable bearer; bodies are
|
|
1637
|
+
JSON unless noted; Drive requests also send `accept: application/json`, as recorded):
|
|
1638
|
+
|
|
1639
|
+
| Route | Behavior |
|
|
1640
|
+
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
1641
|
+
| `GET /gmail/v1/users/me/messages` | `labelIds`, `maxResults`, `pageToken`: `{ messages: [{ id, threadId }], nextPageToken? }` |
|
|
1642
|
+
| `GET /gmail/v1/users/me/messages/{messageId}` | `format=minimal`, `metadata`, or `full`, as recorded for that message; the recorded 404 |
|
|
1643
|
+
| `GET /gmail/v1/users/me/messages/{messageId}/attachments/{id}` | `{ size, data }` (base64url) of a seeded attachment |
|
|
1644
|
+
| `POST /gmail/v1/users/me/messages/{messageId}/modify` | `{ addLabelIds: [<created label>] }`: `{ id, threadId, labelIds }` |
|
|
1645
|
+
| `POST /gmail/v1/users/me/messages/{messageId}/trash`, `/untrash` | No body: adds or removes `TRASH`, answering `{ id, threadId, labelIds }` |
|
|
1646
|
+
| `POST /gmail/v1/users/me/labels` | `{ name: "yolk-conformance <runId> label" }`: the created user label (`Label_9101` first) |
|
|
1647
|
+
| `GET`, `DELETE /gmail/v1/users/me/labels/{labelId}` | Delete a created label (204; it leaves every message); a read of an absent label answers 404 |
|
|
1648
|
+
| `POST /gmail/v1/users/me/drafts`, `PUT .../drafts/{draftId}` | The recorded run draft without recipients: `{ id, message: { id, threadId, labelIds } }` |
|
|
1649
|
+
| `DELETE /gmail/v1/users/me/drafts/{draftId}` | 204 (the draft and its message go); an absent draft answers the recorded 404 |
|
|
1650
|
+
| `GET /gmail/v1/users/me/threads/{threadId}` | `format=full`: a draft thread created here |
|
|
1651
|
+
| `POST /upload/gmail/v1/users/me/messages/send` | `uploadType=multipart`, `multipart/related`: the practice message only (see below) |
|
|
1652
|
+
| `GET`, `POST /calendar/v3/calendars/{calendarId}/events` | Range listing (`singleEvents=true`, `orderBy=startTime`) with page tokens; the run event |
|
|
1653
|
+
| `GET`, `PATCH`, `DELETE .../events/{eventId}` | Read (cancelled too), the recorded rename, delete to `cancelled` (204), then the recorded 410 |
|
|
1654
|
+
| `GET`, `POST /drive/v3/files` | Folder listing (`'<folder>' in parents and trashed = false`) with page tokens; the run folder |
|
|
1655
|
+
| `GET`, `PATCH`, `DELETE /drive/v3/files/{fileId}` | Read with the connector `fields` (absent: the recorded 404), `{ trashed: true }`, delete 204 |
|
|
1656
|
+
|
|
1657
|
+
**The practice send is recorded, never delivered.** `google.gmail.send-practice-address` is
|
|
1658
|
+
irreversible on Gmail. The emulator accepts only the recorded 7-bit message whose sole recipient
|
|
1659
|
+
header is `To: <the seeded practiceAddress>` (no `Cc`, `Bcc`, other address, or list) with the
|
|
1660
|
+
run-scoped subject and `{}` metadata, records it in the state (a message with the `SENT` label whose
|
|
1661
|
+
`format=metadata` read answers the recorded headers, with the `Date` header from the `now` clock and
|
|
1662
|
+
a minted `Message-ID`), and delivers nothing anywhere; only `reset` or `seed` drops it. A seed may
|
|
1663
|
+
set another `practiceAddress`, but then every send answers 400 not-emulated (the reason names the
|
|
1664
|
+
recorded `practice@example.test`): the recorded `sizeEstimate` of the sent message covers its
|
|
1665
|
+
address, as it covers its subject, so draft and send subjects need a run id of the fixtures' length
|
|
1666
|
+
(13 characters; see the latitude below). The draft metadata `body.size` (64) and the sent message
|
|
1667
|
+
`body.size` (88) are the fixtures' recorded values, answered as recorded.
|
|
1668
|
+
|
|
1669
|
+
**Fail closed: the shared rule.** Google follows the shared fail-closed rule of the stateful
|
|
1670
|
+
wrapper, exactly as the GitHub emulator states it above: every route parameter has a raw pattern
|
|
1671
|
+
matched in full (Gmail ids, a calendar id whose only encoding is `%40`, event ids, Drive ids), so a
|
|
1672
|
+
request is recognised only when its raw path is exactly an emulated route shape under that route's
|
|
1673
|
+
method and any `Authorization` header is exactly `Bearer <token>` with a recognisable bearer (an RFC
|
|
1674
|
+
6750 `b64token` of at least 8 characters, starting with a character in `[G-Zg-z\-._~+/]` other than
|
|
1675
|
+
`n`, `r`, `t`, `u`, with at least one outside `[0-9.eE+-]`; Google's `ya29.…` tokens qualify). Every
|
|
1676
|
+
other request is ledgered and answered with constant text only: the path `/<unrecognised>`, a
|
|
1677
|
+
standard method or `<other>`, no query, no body, and a constant reason. The bearer value is never
|
|
1678
|
+
compared against anything, stored, forwarded, or ledgered. A recognised request that repeats it in
|
|
1679
|
+
its raw path, any path segment, the query or any query key or value, the recorded `content-type`
|
|
1680
|
+
header, or its raw body (the multipart send body included), or, on the draft compose and update
|
|
1681
|
+
routes, in the base64url-decoded MIME of `message.raw` (a decoded view the routes give the wrapper),
|
|
1682
|
+
through any depth of percent-encoding or JSON escaping, is ledgered as the constant
|
|
1683
|
+
credential-repeat entry; any other recognised request has it scrubbed from its ledgered fields and
|
|
1684
|
+
every not-emulated message. A `message.raw` the route would refuse (anything but canonical unpadded
|
|
1685
|
+
base64url of exactly the recorded draft MIME, the run id aside) makes the view throw a
|
|
1686
|
+
`DecodedViewRefusal` with one of the route's declared constant reasons (`viewRefusalReasons`: the
|
|
1687
|
+
canonical-base64url reason, the other-than-the-recorded-run-draft reason, the 13-character run-id
|
|
1688
|
+
reason, the extra-key reason), which the wrapper ledgers in the constant entry before anything is
|
|
1689
|
+
recorded (or `the request body repeats the credential` when the raw decodes cleanly to text holding
|
|
1690
|
+
the bearer), so a refused `message.raw` never reaches the ledger. Refusals never echo a request's
|
|
1691
|
+
own query or body keys.
|
|
1692
|
+
|
|
1693
|
+
Every answer value comes from a fixture, through the seed or the request, except the values the
|
|
1694
|
+
emulator mints (it never mints anything else):
|
|
1695
|
+
|
|
1696
|
+
- **Minted values.** Created label ids (`Label_9101`, ...) start above every seeded label number
|
|
1697
|
+
(label ids are `Label_<1 to 999999999>`: a seeded `Label_<digits>` id outside that form is
|
|
1698
|
+
rejected, and a create when no number is left is not emulated); draft ids
|
|
1699
|
+
(`r-8000000000000000001`, ...), draft and sent message ids (`18f00000000000d1`,
|
|
1700
|
+
`18f00000000000e1`, ...; a created draft is its own thread), event ids
|
|
1701
|
+
(`syntheticconformance0001`, ...), and folder ids (`synthetic-conformance-folder-0001`, ...) use
|
|
1702
|
+
forms no seeded id or thread id may use. All come from counters in the state that only advance.
|
|
1703
|
+
Event `created` / `updated`, folder `createdTime` / `modifiedTime` / `trashedTime`, and the sent
|
|
1704
|
+
`Date` header come from the injectable `now` clock. Page tokens are the fixtures' values
|
|
1705
|
+
(`synthetic-gmail-page-2`, ...) in the generation that first issued them, and
|
|
1706
|
+
`<token>.g<generation>` after a reset or seed (each starts a generation); a token is never
|
|
1707
|
+
rebound: token values are globally unique, so another list or page size, or a changed list, gets a
|
|
1708
|
+
distinct `<token>.v<k>`.
|
|
1709
|
+
- **Implied entities.** The paging label (`impliedLabelIds`), the five messages its listing names
|
|
1710
|
+
(`impliedMessages`, rendered only as `{ id, threadId }` list entries), and the practice Drive
|
|
1711
|
+
folder (`impliedFolderIds`) are only named by the fixtures: references resolve through them (a
|
|
1712
|
+
label listing, a folder parent), but an answer that would render one is not emulated.
|
|
1713
|
+
- **Recorded renderings.** A Gmail message answers only the formats a fixture records for it (the
|
|
1714
|
+
work message `minimal`, the attachment message `full`, a composed draft `metadata` and `full`, an
|
|
1715
|
+
updated draft `full`, a sent message `metadata`); another format is not emulated.
|
|
1716
|
+
- **What writes leave.** A reversible case ends at the seed except the counters, plus each event
|
|
1717
|
+
case's own event, which Calendar keeps readable as `cancelled` (the fixtures read it back); the
|
|
1718
|
+
send leaves its sent message. Deleting a label removes it from every message.
|
|
1719
|
+
- **Page tokens.** A token is accepted only when this emulator issued it for the same list (label
|
|
1720
|
+
and `maxResults`; calendar, range, and `maxResults`; folder and `pageSize`) since the last reset
|
|
1721
|
+
or seed, and the list renders exactly as when it was issued.
|
|
1722
|
+
- **Request-shape latitude (`/google`, the only accepted deviations).** Any bearer value in the RFC
|
|
1723
|
+
6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
|
|
1724
|
+
character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
|
|
1725
|
+
`[0-9.eE+-]` (Google's `ya29.…` access tokens qualify), that occurs nowhere else in the request
|
|
1726
|
+
(never compared against anything, stored, or ledgered); extra request headers (except
|
|
1727
|
+
`X-Goog-Drive-Resource-Keys`, which no fixture sends); JSON key order; `content-type` media-type
|
|
1728
|
+
parameters on JSON requests; query parameters in any order; any `run-` run id (at most 40
|
|
1729
|
+
characters) in a run-scoped label name, event summary, or folder name; in a draft subject (compose
|
|
1730
|
+
and update) and the sent subject, only a run id of exactly 13 characters, the length of the
|
|
1731
|
+
fixtures' `run-synthetic`, because the recorded `sizeEstimate` of the draft and sent messages
|
|
1732
|
+
(answered by their message reads and the draft thread) covers the subject; on the practice send, a
|
|
1733
|
+
`content-type` of exactly `multipart/related; boundary=<b>` with any one unquoted boundary of 1 to
|
|
1734
|
+
70 `[A-Za-z0-9_]` characters and no other parameter; a `gmail.list` `maxResults` from 1 to 500, a
|
|
1735
|
+
`calendar.list_events` `maxResults` from 1 to 2500, and a `drive.list_files` `pageSize` from 1 to
|
|
1736
|
+
1000; any `timeMin` before `timeMax` (RFC 3339 instants with a real calendar date, hour 0 to 23,
|
|
1737
|
+
minute and second 0 to 59, and a `Z` or in-range numeric offset); any id of an item the state
|
|
1738
|
+
holds where a fixture has an id (writes: only items created here, plus label changes, trash, and
|
|
1739
|
+
untrash of a stored non-draft message); and, for an id the state does not hold, only the recorded
|
|
1740
|
+
not-found answers (a `format=minimal` read of a 16-hex-digit message id, a read of a
|
|
1741
|
+
`Label_<1 to 999999999>` label, a delete of an `r-<digits>` draft, and a Drive file read). A seed
|
|
1742
|
+
may set another `practiceAddress`, but then every send is refused, since the recorded
|
|
1743
|
+
`sizeEstimate` of the sent message also covers the address: the send answers only while the seeded
|
|
1744
|
+
address is the recorded `practice@example.test`. On the draft compose and update routes,
|
|
1745
|
+
`message.raw` must be canonical unpadded base64url of exactly the recorded draft MIME of that
|
|
1746
|
+
route, the run id aside; any other `message.raw` (line-wrapped, the standard alphabet, padded,
|
|
1747
|
+
with a stray character, or with MIME-level encodings such as RFC 2047 encoded-words,
|
|
1748
|
+
quoted-printable, or UTF-16) is refused before anything is recorded or a fault is decided, as a
|
|
1749
|
+
constant entry with the route's own declared reason
|
|
1750
|
+
(`message.raw must be canonical base64url UTF-8 MIME`,
|
|
1751
|
+
`a draft compose other than the recorded run draft is not emulated` or its `update` form, the
|
|
1752
|
+
13-character run-id reason, or `message has a key this route does not take`), or
|
|
1753
|
+
`the request body repeats the credential` when that raw decodes cleanly to text holding the
|
|
1754
|
+
bearer, so a refused `message.raw` never reaches the ledger and an admitted one is the recorded
|
|
1755
|
+
text. `Authorization` must be exactly `Bearer <token>` (that spelling, one space). Everything else
|
|
1756
|
+
(other keys, values, formats, query parameters, empty query components such as a bare `?` or a
|
|
1757
|
+
stray `&`, recorded headers such as Drive's `accept: application/json` missing, another origin,
|
|
1758
|
+
repeated query parameters, any recipient but the seeded practice address, a draft or send run id
|
|
1759
|
+
of another length, a bearer repeated anywhere in the request, including base64url-encoded inside a
|
|
1760
|
+
draft's `message.raw`, and page tokens not issued for the same list since the last reset or seed,
|
|
1761
|
+
or whose list changed) is not emulated.
|
|
1762
|
+
|
|
1763
|
+
Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
|
|
1764
|
+
`notEmulated` in the ledger), writes nothing, and uses up no fault: among others the label listing,
|
|
1765
|
+
draft listing, and calendar listing, `q` on Gmail or Calendar listings, an empty listing (no fixture
|
|
1766
|
+
records one), a Gmail listing that would leave out messages in Trash or Spam, a Calendar range
|
|
1767
|
+
holding a cancelled event, a Drive listing including trashed items, reads of an existing label or
|
|
1768
|
+
of an implied entity, threads of seeded messages, label changes other than adding one label
|
|
1769
|
+
created here, writes to seeded events and files, and the page tokens above. A route that throws
|
|
1770
|
+
(for example when the injected clock throws while creating an event or a folder, or sending)
|
|
1771
|
+
answers an evidence-tagged 500 `{ error: { type: 'emulator_error', message } }` with
|
|
1772
|
+
`responseError` in the ledger and writes nothing; a closed emulator answers 503. No recovery answer
|
|
1773
|
+
reads the clock.
|
|
1774
|
+
|
|
1775
|
+
State and seeds: `practiceAddress`, Gmail `messages` (with their recorded renderings),
|
|
1776
|
+
`impliedMessages`, `impliedLabelIds`, created `labels`, `attachments`, `drafts`, Calendar
|
|
1777
|
+
`calendars` and `events`, Drive `files` and `impliedFolderIds`, and the counters. The default seed
|
|
1778
|
+
is the synthetic fixture entities with the ids of `googleConformanceFixtureSeeds`. Pass
|
|
1779
|
+
`seed: { profile?, practiceAddress?, messages?, impliedMessages?, impliedLabelIds?, attachments?,
|
|
1780
|
+
calendars?, events?, files?, impliedFolderIds? }` (lists replace the profile's; labels and drafts
|
|
1781
|
+
are never seeded) with profiles `'default'` or `'empty'`. `reset()`, `seed(next)`, and
|
|
1782
|
+
`snapshot()` behave as in the Dropbox emulator; reset and seed also clear issued page tokens.
|
|
1783
|
+
|
|
1784
|
+
Faults and the control plane behave as in the Dropbox emulator (the ledger records only the
|
|
1785
|
+
`content-type` request header); a 429 fault with `retry-after` reaches the connector as
|
|
1786
|
+
`google_rate_limited` with `retryAfterMs`.
|
|
1787
|
+
|
|
1788
|
+
**Drill knobs (tests only).** `drills: { gmailPageRepeats, attachmentStandardBase64,
|
|
1789
|
+
notFoundWithoutMessage, labelDeleteKeepsOnMessages, draftUpdateKeepsContent,
|
|
1790
|
+
trashAnswerOmitsTrash, sentMessageWithoutTo, calendarPageRepeats, eventPatchKeepsSummary,
|
|
1791
|
+
repeatedEventDeleteConflict, drivePageRepeats, getFileWithoutParents, listIncludesTrashed }`
|
|
1792
|
+
(booleans) each make the emulator disagree with exactly one Google case, only to prove that case
|
|
1793
|
+
catches it.
|
|
1794
|
+
|
|
1795
|
+
## LinkedIn search emulator
|
|
1796
|
+
|
|
1797
|
+
> **Node only.** `@yolk-sdk/emulators/linkedin-search` runs on the same pinned `@emulators/core`
|
|
1798
|
+
> runtime, loaded lazily by `makeLinkedInSearchEmulator`, so importing the subpath has no side
|
|
1799
|
+
> effects.
|
|
1800
|
+
|
|
1801
|
+
`await makeLinkedInSearchEmulator(options?)` returns
|
|
1802
|
+
`{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
|
|
1803
|
+
state; `await close()` when done. It emulates only the Exa and Enrich Layer routes the seven
|
|
1804
|
+
LinkedIn search conformance cases send, so the LinkedIn search connector actions and the cases run
|
|
1805
|
+
unchanged against it. Each route answers only on the origin its fixtures record: the Exa people
|
|
1806
|
+
search on `https://api.exa.ai` (`linkedInSearchEmulatorExaOrigin`), the Enrich Layer profile and
|
|
1807
|
+
email lookups on `https://enrichlayer.com` (`linkedInSearchEmulatorEnrichLayerOrigin`, under the
|
|
1808
|
+
connector's `/api/v2` base). `fetch` takes the origin from the request URL (in-process routing keeps
|
|
1809
|
+
it); behind a loopback rewrite, which loses it, serve `fetchOn(origin)` for each origin on its own
|
|
1810
|
+
server:
|
|
1811
|
+
|
|
1812
|
+
```ts
|
|
1813
|
+
import {
|
|
1814
|
+
linkedInSearchEmulatorEnrichLayerOrigin,
|
|
1815
|
+
linkedInSearchEmulatorExaOrigin,
|
|
1816
|
+
makeLinkedInSearchEmulator
|
|
1817
|
+
} from '@yolk-sdk/emulators/linkedin-search'
|
|
1818
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
1819
|
+
|
|
1820
|
+
const linkedIn = await makeLinkedInSearchEmulator()
|
|
1821
|
+
|
|
1822
|
+
const httpLayer = InProcessHttpClient.layer([
|
|
1823
|
+
EmulatorRoute.handler(linkedInSearchEmulatorExaOrigin, linkedIn.fetch),
|
|
1824
|
+
EmulatorRoute.handler(linkedInSearchEmulatorEnrichLayerOrigin, linkedIn.fetch)
|
|
1825
|
+
])
|
|
1826
|
+
// With serveFetchHandler: one server per origin, serving linkedIn.fetchOn(origin).
|
|
1827
|
+
// ...run the code under test, then:
|
|
1828
|
+
await linkedIn.close()
|
|
1829
|
+
```
|
|
1830
|
+
|
|
1831
|
+
Routes (every request needs `Authorization: Bearer <key>`, a recognisable bearer; each provider
|
|
1832
|
+
takes its own key, and both are reads):
|
|
1833
|
+
|
|
1834
|
+
| Route | Behavior |
|
|
1835
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
1836
|
+
| `POST /search` | `{ query, category: "people", numResults, type: "auto", contents: { text: true } }`: `{ results }` |
|
|
1837
|
+
| `GET /api/v2/profile` | `linkedin_profile_url`: a held profile, or the recorded 404 for a seeded absent profile |
|
|
1838
|
+
| `GET /api/v2/profile/email` | `linkedin_profile_url`: `{ email }` of a held profile |
|
|
1839
|
+
|
|
1840
|
+
Every answer comes from a fixture, byte for byte, through the seed; the emulator mints nothing (no
|
|
1841
|
+
ids, cursors, or clock reads), so nothing is ever written and every case ends exactly at its seed:
|
|
1842
|
+
|
|
1843
|
+
- **Searches.** A search answers only the results the state holds for exactly its query and
|
|
1844
|
+
`numResults`. The default seed holds the three answers the fixtures record for the seeded query:
|
|
1845
|
+
`numResults` 10 (the people-results case), and 3 and 2 (the control and the limited search of the
|
|
1846
|
+
num-results-limit case, which answers its second result without `publishedDate`, as recorded).
|
|
1847
|
+
Another query or `numResults` (the unauthorized probe with an accepted key, for example) is not
|
|
1848
|
+
emulated; no fixture records an empty search.
|
|
1849
|
+
- **Profiles.** A profile lookup answers a held profile in the profile fixture's fields
|
|
1850
|
+
(`public_identifier`, `full_name`, `headline`), and an absent profile (the seeded
|
|
1851
|
+
`absentProfileUrl`) the recorded 404 body; an email lookup answers a held profile's recorded
|
|
1852
|
+
`{ email }`. Any other profile URL, and the email of an absent profile, is not emulated.
|
|
1853
|
+
- **Rejected keys, per origin.** A key the seed marks as rejected on an origin answers that
|
|
1854
|
+
origin's recorded 401 body (Exa's `{ requestId, error }`, Enrich Layer's
|
|
1855
|
+
`{ code, description, name }`), for any request of the emulated shape on that origin. The default
|
|
1856
|
+
seed rejects the synthetic invalid keys the two unauthorized cases send
|
|
1857
|
+
(`yolk-conformance-invalid-exa-key` on Exa, `yolk-conformance-invalid-enrich-layer-key` on Enrich
|
|
1858
|
+
Layer); a key rejected on one origin is accepted on the other. The error bodies are
|
|
1859
|
+
`linkedInSearchEmulatorErrorBodies`.
|
|
1860
|
+
|
|
1861
|
+
**Fail closed: the shared rule, and keys kept only as digests.** LinkedIn search follows the shared
|
|
1862
|
+
fail-closed rule of the stateful wrapper, exactly as the GitHub emulator states it above: a request
|
|
1863
|
+
is recognised only when its raw path is exactly an emulated route path under that route's method and
|
|
1864
|
+
any `Authorization` header is exactly `Bearer <key>` with a recognisable bearer (an RFC 6750
|
|
1865
|
+
`b64token` of at least 8 characters, starting with a character in `[G-Zg-z\-._~+/]` other than `n`,
|
|
1866
|
+
`r`, `t`, `u`, with at least one outside `[0-9.eE+-]`; a UUID-form key, which starts with a hex
|
|
1867
|
+
digit, is unrecognisable, so hand the emulator a synthetic key). Every other request is ledgered and
|
|
1868
|
+
answered with constant text only. A recognised request that repeats its bearer in the raw path, the
|
|
1869
|
+
query or any query key or value, the recorded `content-type` header, or the body, through any depth
|
|
1870
|
+
of percent-encoding or JSON escaping, is ledgered as the constant credential-repeat entry; any other
|
|
1871
|
+
has it scrubbed from its ledgered fields and every not-emulated message. The bearer is never stored
|
|
1872
|
+
or ledgered: through the wrapper's opt-in per-origin `bearerDigest`, routes see only SHA-256 of the
|
|
1873
|
+
origin, a space, and the key, which a plan compares with the digests of the seed's rejected keys for
|
|
1874
|
+
that origin. The state holds only those digests, so a key a request carries as its bearer, a
|
|
1875
|
+
rejected one included, never reaches the state, a snapshot, or `/_emulate/*` (a key sent as data
|
|
1876
|
+
elsewhere in a request is ledgered like any other text). Refusals never echo a request's own query
|
|
1877
|
+
or body keys. This guarding covers the emulated provider API calls only: `/_emulate/*` is the
|
|
1878
|
+
host's trusted control plane, and the seed and fault data a host gives it is stored and returned as
|
|
1879
|
+
given.
|
|
1880
|
+
|
|
1881
|
+
- **Request-shape latitude (`/linkedin-search`, the only accepted deviations).** Any bearer value in
|
|
1882
|
+
the RFC 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
|
|
1883
|
+
character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
|
|
1884
|
+
`[0-9.eE+-]`, that occurs nowhere else in the request (never stored or ledgered; only its
|
|
1885
|
+
per-origin digest is compared, with the digests of the keys the seed marks as rejected on that
|
|
1886
|
+
origin); extra request headers; JSON key order; `content-type` media-type parameters on the
|
|
1887
|
+
search; any percent-encoding of the `linkedin_profile_url` value that decodes once to the same
|
|
1888
|
+
profile URL; and, with a key the seed marks as rejected on the request's origin, any search
|
|
1889
|
+
`query` (one trimmed line of at most 500 characters) with any integer `numResults` from 1 to 100,
|
|
1890
|
+
and any profile URL of the form `https://<host>/in/<slug>`, each answered the origin's
|
|
1891
|
+
recorded 401. `Authorization` must be exactly `Bearer <token>` (that spelling, one space).
|
|
1892
|
+
Everything else (other body keys or values, a `category` other than `people`, a `type` other than
|
|
1893
|
+
`auto`, `contents` other than `{ "text": true }`, a search the state holds no answer for (another
|
|
1894
|
+
query, or a `numResults` no seeded search of that query records), a profile URL the state holds
|
|
1895
|
+
neither as a profile nor as absent, an email lookup of an absent profile, any query parameter on
|
|
1896
|
+
the search, other, missing, or repeated query parameters on a lookup, a query parameter name in
|
|
1897
|
+
any but its plain form (such as `%6cinkedin_profile_url`), empty query components such as a bare
|
|
1898
|
+
`?` or a stray `&`, a body on a lookup, another origin, and a bearer repeated anywhere in the
|
|
1899
|
+
request) is not emulated.
|
|
1900
|
+
|
|
1901
|
+
Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
|
|
1902
|
+
`notEmulated` in the ledger) and uses up no fault. A closed emulator answers 503; a route that
|
|
1903
|
+
throws answers an evidence-tagged 500 with `responseError` in the ledger.
|
|
1904
|
+
|
|
1905
|
+
State and seeds: `searches` (`{ query, numResults, results }`, 1 to `numResults` results with the
|
|
1906
|
+
fixtures' optional `title`, `url`, `author`, `publishedDate`, and `text`), `profiles`
|
|
1907
|
+
(`{ url, publicIdentifier, fullName, headline, email? }`), `absentProfileUrls`, and the digests of
|
|
1908
|
+
the rejected keys (`exaRejectedKeyDigests`, `enrichLayerRejectedKeyDigests`). Pass
|
|
1909
|
+
`seed: { searches?, profiles?, absentProfileUrls?, exaRejectedKeys?, enrichLayerRejectedKeys? }`:
|
|
1910
|
+
each given list replaces the default seed's (the fixture entities); rejected keys are given as keys
|
|
1911
|
+
(recognisable bearer values) and kept only as their digests, and no seed error quotes one: every
|
|
1912
|
+
seed error is constant text, a category and a field path (`duplicate search at searches[1]`,
|
|
1913
|
+
`unexpected key at the seed root`). `reset()`, `seed(next)`, and `snapshot()` behave as in the
|
|
1914
|
+
Dropbox emulator.
|
|
1915
|
+
|
|
1916
|
+
Faults and the control plane behave as in the Dropbox emulator (the ledger records only the
|
|
1917
|
+
`content-type` request header); the connector maps every non-2xx answer to its action's failure
|
|
1918
|
+
code, so a 429 fault reaches it as `linkedin_search_failed` (or `linkedin_profile_failed`,
|
|
1919
|
+
`linkedin_email_failed`) with status 429 and no `retryAfterMs`.
|
|
1920
|
+
|
|
1921
|
+
**Drill knobs (tests only).** `drills: { defaultSearchWithoutText, numResultsIgnored,
|
|
1922
|
+
profileAnswersEmptyObject, emailAnswerOmitsEmail, exaUnauthorizedAs5xx,
|
|
1923
|
+
enrichLayerUnauthorizedAs2xx, absentProfileAs2xx }` (booleans) each make the emulator disagree with
|
|
1924
|
+
exactly one LinkedIn search case, only to prove that case catches it.
|
|
1925
|
+
|
|
1926
|
+
## MCP emulator
|
|
1927
|
+
|
|
1928
|
+
> **Node only.** `@yolk-sdk/emulators/mcp` runs on the same pinned `@emulators/core` runtime,
|
|
1929
|
+
> loaded lazily by `makeMcpEmulator`, so importing the subpath has no side effects.
|
|
1930
|
+
|
|
1931
|
+
`await makeMcpEmulator(options?)` returns
|
|
1932
|
+
`{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
|
|
1933
|
+
state and session counter; `await close()` when done. It emulates the two synthetic servers of the
|
|
1934
|
+
`@yolk-sdk/mcp/conformance` fixtures, so `@yolk-sdk/mcp/client` and the MCP conformance cases run
|
|
1935
|
+
unchanged against it: profile `synthetic-modern` on `https://mcp.example.test/modern/mcp`
|
|
1936
|
+
(stateless `2026-07-28`, JSON answers) and profile `synthetic-legacy` on
|
|
1937
|
+
`https://mcp.example.test/legacy/mcp` (an `initialize` handshake, sessions, SSE answers). Both
|
|
1938
|
+
answer only on that origin (`mcpEmulatorOrigin`); behind a loopback rewrite, serve
|
|
1939
|
+
`fetchOn(mcpEmulatorOrigin)`:
|
|
1940
|
+
|
|
1941
|
+
```ts
|
|
1942
|
+
import { makeMcpEmulator, mcpEmulatorOrigin } from '@yolk-sdk/emulators/mcp'
|
|
1943
|
+
import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
|
|
1944
|
+
|
|
1945
|
+
const mcp = await makeMcpEmulator()
|
|
1946
|
+
|
|
1947
|
+
const httpLayer = InProcessHttpClient.layer([EmulatorRoute.handler(mcpEmulatorOrigin, mcp.fetch)])
|
|
1948
|
+
// Remote server config: { type: 'remote', url: 'https://mcp.example.test/modern/mcp',
|
|
1949
|
+
// headers: { authorization: 'Bearer <a synthetic token>' } }
|
|
1950
|
+
// ...run the code under test, then:
|
|
1951
|
+
await mcp.close()
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
Routes (`mcpEmulatorRoutes`: one `RPC <origin><path>#<method>` row per recorded JSON-RPC method of
|
|
1955
|
+
each profile, plus the legacy `GET` row; every row is a read, and every request needs
|
|
1956
|
+
`Authorization: Bearer <token>`, a recognisable bearer):
|
|
1957
|
+
|
|
1958
|
+
| Row | Answer (the recorded bytes) |
|
|
1959
|
+
| --------------------------------------- | ------------------------------------------------------------------------------- |
|
|
1960
|
+
| `/modern/mcp#server/discover` | The modern discover result |
|
|
1961
|
+
| `/modern/mcp#tools/list` | The one-page listing, or the paged listing's pages (seed `two-pages`) |
|
|
1962
|
+
| `/modern/mcp#tools/call` | The read result, the `isError` tool result, or the absent tool's 400 error |
|
|
1963
|
+
| `/legacy/mcp#server/discover` | The recorded 400 JSON-RPC error that makes the client fall back to `initialize` |
|
|
1964
|
+
| `/legacy/mcp#initialize` | The SSE `initialize` result with a minted `mcp-session-id` |
|
|
1965
|
+
| `/legacy/mcp#notifications/initialized` | 202, no body, on an initializing session (which becomes ready) |
|
|
1966
|
+
| `/legacy/mcp#tools/list` | The SSE listing, on a ready session |
|
|
1967
|
+
| `/legacy/mcp#tools/call` | The three recorded SSE call answers, on a ready session |
|
|
1968
|
+
| `GET /legacy/mcp` | 405, no body, on a ready session |
|
|
1969
|
+
| `/{modern,legacy}/mcp#server/discover` | With the reserved invalid credential: the recorded 401, byte for byte |
|
|
1970
|
+
|
|
1971
|
+
Every answer comes from a fixture, byte for byte (the copies live in `mcpEmulatorFixtures`), with
|
|
1972
|
+
exactly three request-derived or minted substitutions:
|
|
1973
|
+
|
|
1974
|
+
- **The request id**, at exactly the recorded place: the top-level `id` of a JSON answer, or the
|
|
1975
|
+
`id` of the SSE response event's payload. Notification events and SSE `id:` lines stay byte for
|
|
1976
|
+
byte, and an answer that does not carry the recorded request id (the legacy era probe's
|
|
1977
|
+
`id: null` error, the 401) is unchanged.
|
|
1978
|
+
- **The session id.** `initialize` mints `yolk-emu-session-<n>`, `n` from a counter that never
|
|
1979
|
+
resets (reset, seed, and a ledger clear never rewind it), a form no seed can hold (seeds hold no
|
|
1980
|
+
sessions); the SSE answers carry the session's id in the recorded `mcp-session-id` header. At most
|
|
1981
|
+
256 sessions are held (`mcpEmulatorSessionCap`): another `initialize` is refused before any
|
|
1982
|
+
fault. `reset()` and `seed()` clear the sessions.
|
|
1983
|
+
- **The cursor.** With `seed: { modernListing: 'two-pages' }`, the first page issues the recorded
|
|
1984
|
+
`synthetic-cursor-0001` in the generation that first issues it and `<cursor>.g<generation>` after
|
|
1985
|
+
a reset or seed (each starts a generation); the second page answers only the cursor issued in the
|
|
1986
|
+
current generation.
|
|
1987
|
+
|
|
1988
|
+
**Fail closed: the shared rule, every header, the output, and the reserved credential seen only as a
|
|
1989
|
+
digest.** MCP follows the shared fail-closed rule of the stateful wrapper, exactly as the GitHub
|
|
1990
|
+
emulator states it above, with its opt-in constant refusals: every refusal, by shape or by state, is
|
|
1991
|
+
ledgered with constant text only (`/<unrecognised>`, a standard method or `<other>`, an empty query,
|
|
1992
|
+
no headers or body, a constant reason, and the route template or row), so request text reaches the
|
|
1993
|
+
ledger only once a request equals a recorded one. A recognised request that repeats its bearer in
|
|
1994
|
+
the path, the query, any request header name or value other than `Authorization` (the wrapper's
|
|
1995
|
+
opt-in `guardAllHeaders`, whatever the ledger records), or the body, through any depth of
|
|
1996
|
+
percent-encoding or JSON escaping, is refused the same way. The output is guarded too (the opt-in
|
|
1997
|
+
`guardOutput`): before any fault is decided or anything is committed, the prepared answer (every
|
|
1998
|
+
header and chunk) and the minted session id or cursor the request would store are checked for the
|
|
1999
|
+
bearer, and a hit is refused with the constant credential-repeat entry
|
|
2000
|
+
(`the answer would repeat the credential`), no fault used and nothing written. So a bearer such as
|
|
2001
|
+
`yolk-emu-session-1` on a fresh emulator, or `synthetic-mcp` (inside the recorded
|
|
2002
|
+
`yolk-synthetic-mcp`), never reaches a response, the state, or `/_emulate/*`. Scope: the bearer is
|
|
2003
|
+
never copied from the request into a response, the state, or `/_emulate/*`; the output guard also
|
|
2004
|
+
refuses a prepared fixture answer, minted session id, or cursor that happens to contain it, but the
|
|
2005
|
+
emulator's other constants (state values such as `initializing`, wrapper headers such as
|
|
2006
|
+
`x-emulator-evidence`) and host-configured control-plane data (a fault body) may coincidentally
|
|
2007
|
+
equal a bearer and are not checked. The bearer is never stored, ledgered, or echoed: routes see only
|
|
2008
|
+
its digest (the wrapper's opt-in `bearerDigest`: SHA-256 of the origin, a space, and the bearer),
|
|
2009
|
+
which they compare only with the digest of the public reserved invalid credential
|
|
2010
|
+
`yolk-conformance-invalid-credential-0000` (`mcpEmulatorReservedInvalidCredential`, itself a
|
|
2011
|
+
recognisable bearer) to answer the recorded 401 on the era probe.
|
|
2012
|
+
|
|
2013
|
+
- **Request-shape latitude (`/mcp`, the only accepted deviations).** Any bearer value in the RFC
|
|
2014
|
+
6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
|
|
2015
|
+
character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
|
|
2016
|
+
`[0-9.eE+-]`, that occurs nowhere else in the request (any header name or value included) and in
|
|
2017
|
+
no answer or value the request would store (never stored or ledgered; only its digest is compared,
|
|
2018
|
+
with the digest of the public reserved invalid credential
|
|
2019
|
+
`yolk-conformance-invalid-credential-0000`, which answers the recorded 401 on the era probe);
|
|
2020
|
+
extra request headers, except `mcp-*` headers other than `mcp-method`, `mcp-name`,
|
|
2021
|
+
`mcp-protocol-version`, and `mcp-session-id`; a recorded header value sent as several headers that
|
|
2022
|
+
the HTTP layer joins into the recorded value; JSON key order; any JSON-RPC request id that is an
|
|
2023
|
+
integer from 0 to 2^53 - 1 or 1 to 64 printable ASCII characters where the recording has an id;
|
|
2024
|
+
any non-empty `name` and `version` (and no other key) in the `_meta` client info
|
|
2025
|
+
(`io.modelcontextprotocol/clientInfo`) of a modern request; a session id this emulator minted
|
|
2026
|
+
since the last reset or seed where the recording sends `mcp-session-id` (initializing for
|
|
2027
|
+
`notifications/initialized`, ready otherwise); and, with the seed's `two-pages` listing, the
|
|
2028
|
+
cursor this emulator issued in the current generation on the second page. `Authorization` must be
|
|
2029
|
+
exactly `Bearer <token>` (that spelling, one space). Everything else (another origin or path, any
|
|
2030
|
+
query, other HTTP methods such as `DELETE` or a `GET` on the modern profile, JSON-RPC methods no
|
|
2031
|
+
fixture of the profile records such as `ping`, `resources/*`, or `prompts/*`, batches and
|
|
2032
|
+
client-sent responses, other members, a JSON body repeating a key (compared after unescaping), a
|
|
2033
|
+
`null`, negative, or fractional id, other params (other tools, arguments, protocol versions, or
|
|
2034
|
+
capabilities, extra client-info keys, and a legacy `initialize` client info other than the
|
|
2035
|
+
recorded one), the MCP headers `accept`, `content-type`, `mcp-method`, `mcp-protocol-version`,
|
|
2036
|
+
`mcp-name`, and `last-event-id` other than the recorded values or present where none is recorded,
|
|
2037
|
+
any other `mcp-*` header (such as `mcp-param-*`), `mcp-session-id` missing where recorded or
|
|
2038
|
+
present where not, an unknown session or one in the wrong phase, a cursor not issued in the
|
|
2039
|
+
current generation, the reserved invalid credential on anything but the era probe, a bearer
|
|
2040
|
+
repeated anywhere in the request, and a bearer an answer or a minted session id or cursor would
|
|
2041
|
+
repeat) is not emulated.
|
|
2042
|
+
|
|
2043
|
+
Not emulated (a constant-text 400 that writes nothing and uses up no fault): `DELETE` (the client
|
|
2044
|
+
never sends it), `ping`, `resources/*`, `prompts/*`, `logging/*`, `completion/*`, `tasks/*`,
|
|
2045
|
+
JSON-RPC batches and client-sent responses, a JSON body repeating a key (compared after unescaping;
|
|
2046
|
+
the wrapper's opt-in `uniqueJsonKeys`), `mcp-*` headers no recording carries (such as
|
|
2047
|
+
`mcp-param-*`), cursors this emulator did not issue, any other tool or arguments, any other origin,
|
|
2048
|
+
path, or query, and a missing `Authorization` (no fixture records the answer to one). A closed
|
|
2049
|
+
emulator answers 503; a route that throws answers an evidence-tagged 500 with `responseError` in the
|
|
2050
|
+
ledger. `makeMcpEmulator` throws when a copied recording is not canonical JSON (every JSON body and
|
|
2051
|
+
SSE `data:` payload equal to `JSON.stringify(JSON.parse(text))`), which id substitution relies on.
|
|
2052
|
+
|
|
2053
|
+
Faults are status faults (400-599) and `truncate-after-chunks` faults (`McpFault`), decided only
|
|
2054
|
+
after a request is admitted and planned, against the answer the plan prepared; a faulted request
|
|
2055
|
+
writes nothing. In `match`, `method` is the HTTP method (`POST` or `GET`, never a row's `RPC`),
|
|
2056
|
+
`path` the raw request path, and `route` one manifest row (for example
|
|
2057
|
+
`https://mcp.example.test/legacy/mcp#tools/list`); a `route` naming no row is rejected when the
|
|
2058
|
+
fault is added. An SSE answer has two chunks (the notification, then the response) and a JSON answer
|
|
2059
|
+
one, so truncating an SSE answer after one chunk ends the stream before its response: the client
|
|
2060
|
+
fails the operation as an `McpError` at its timeout, never hangs. A truncation sends the prepared
|
|
2061
|
+
answer cut short and never runs its commit: a truncated `initialize` holds no session and leaves the
|
|
2062
|
+
counter and the session cap where they were, and a truncated first page of the two-page listing
|
|
2063
|
+
issues no cursor, so its continuation is refused. A truncation that cannot apply (a 202 or 405 has
|
|
2064
|
+
no chunk) answers 500 and is not used up. The ledger, coverage (per row), recovery, and the control
|
|
2065
|
+
plane behave as in the Dropbox emulator; `/_emulate/state` also reports `nextSession`,
|
|
2066
|
+
`cursorGeneration`, and `issuedCursor`.
|
|
2067
|
+
|
|
2068
|
+
**Drill knobs (tests only).** `drills: { discoverCarriesErrorResponse, discoverWithoutResultType,
|
|
2069
|
+
sessionIdNotVisibleAscii, discoverAnsweredTwice, writeToolMarkedReadOnly, readCallAnswersToolError,
|
|
2070
|
+
invalidCallAnswersRpcError, absentCallAnswersResult, unauthorizedWithoutChallenge }` (booleans) each
|
|
2071
|
+
make the emulator disagree with exactly one MCP conformance case, only to prove that case catches
|
|
2072
|
+
it.
|
|
2073
|
+
|
|
2074
|
+
## Evidence
|
|
2075
|
+
|
|
2076
|
+
`gatewayEmulatorRoutes`, `gatewayEvaluateEmulatorRoutes`, `openAiEmulatorRoutes`,
|
|
2077
|
+
`anthropicEmulatorRoutes`, `codexEmulatorRoutes`, `xAiGrokEmulatorRoutes`, `openCodeGoEmulatorRoutes`, the three subscription-usage manifests,
|
|
2078
|
+
`emailEmulatorRoutes`, `r2EmulatorRoutes`, `fortnoxEmulatorRoutes`, `microsoftEmulatorRoutes`,
|
|
2079
|
+
`dropboxEmulatorRoutes`, `notionEmulatorRoutes`, `todoistEmulatorRoutes`, `telegramEmulatorRoutes`,
|
|
2080
|
+
`githubEmulatorRoutes`, `googleEmulatorRoutes`, `linkedInSearchEmulatorRoutes`, and
|
|
2081
|
+
`mcpEmulatorRoutes` (one `RPC <origin><path>#<method>` row per JSON-RPC method) list every
|
|
2082
|
+
emulated route with `method`, `path`, `kind`, `write`, the conformance `caseIds` it follows,
|
|
2083
|
+
`evidence` (`verified` or `unverified`), and `observedAt`. Every response from an unverified route
|
|
2084
|
+
of a fetch-handler emulator carries `x-emulator-evidence: unverified`; the email and R2 emulators
|
|
2085
|
+
record evidence on each ledger entry instead, since their plain-JSON replies carry no header. The
|
|
2086
|
+
Gateway route is `verified` (`observedAt: '2026-09-30'`): its wire shapes are checked against the
|
|
2087
|
+
verified live recordings. Every other route (OpenAI, Anthropic, Codex, Grok, OpenCode Go, the usage
|
|
2088
|
+
routes, email, R2, Fortnox, Microsoft, Dropbox, Notion, Todoist, Telegram, GitHub, Google,
|
|
2089
|
+
LinkedIn search, and MCP) is unverified, like the synthetic fixtures it follows.
|
|
2090
|
+
Each manifest route maps to its own handler; an emulator whose manifest has a route without a
|
|
2091
|
+
handler throws when it is constructed. The Yolk repository checks these manifests: unknown case ids,
|
|
2092
|
+
duplicate routes, connector write routes without verified evidence, verified connector write routes
|
|
2093
|
+
whose `observedAt` is missing, unreadable, or in the future, and verified routes whose cited cases
|
|
2094
|
+
have no verified fixture fail, as do verified connector write routes citing no cases; unverified or
|
|
2095
|
+
stale (over 30 days) evidence and other routes citing no cases warn.
|
|
2096
|
+
|
|
2097
|
+
The email emulator's eight write routes are unverified connector writes. Until an owner-approved
|
|
2098
|
+
live run against a practice mailbox verifies them, the repository lists them in a visible,
|
|
2099
|
+
time-bounded allowlist (`scripts/emulator-evidence-pending.json`, which holds each entry's expiry
|
|
2100
|
+
date): the check reports them as PENDING warnings until that date and fails again after it. The
|
|
2101
|
+
R2 emulator's one write route, `PORT R2ObjectClient.put`, is held in the same list until an
|
|
2102
|
+
owner-approved live run against a practice bucket, through a host `R2ObjectClient`
|
|
2103
|
+
implementation, verifies it.
|
|
2104
|
+
|
|
2105
|
+
All Fortnox, Microsoft, Dropbox, Notion, Todoist, Telegram, GitHub, Google, LinkedIn search, and
|
|
2106
|
+
MCP routes are currently `unverified` (no live recording yet), including four Fortnox, eleven
|
|
2107
|
+
Microsoft, five Dropbox, two Notion, five Todoist, one Telegram, six GitHub, and fifteen Google
|
|
2108
|
+
connector write routes (the three LinkedIn search routes and the nine MCP rows are reads, so none of
|
|
2109
|
+
them needs an entry). Until an
|
|
2110
|
+
owner-approved live run verifies them, the repository lists them in a visible, time-bounded
|
|
2111
|
+
allowlist (`scripts/emulator-evidence-pending.json`, which holds each entry's expiry date): the
|
|
2112
|
+
check reports them as PENDING warnings until that date and fails again after it.
|
|
2113
|
+
|
|
2114
|
+
## Node server
|
|
2115
|
+
|
|
2116
|
+
`serveFetchHandler(handler, { port = 0 })` serves on `127.0.0.1` only (any other `host` is
|
|
2117
|
+
refused) as a scoped Effect resource returning `{ url, close }`. Streamed bodies are written chunk
|
|
2118
|
+
by chunk, so progressive delivery survives the socket; a body stream error drops the connection.
|
|
2119
|
+
`startFetchHandlerServer(handler, { port = 0 })` is the same server as a Promise, for
|
|
2120
|
+
non-Effect test runners and hosts without an Effect runtime:
|
|
2121
|
+
|
|
2122
|
+
```ts
|
|
2123
|
+
import { makeGatewayEmulator } from '@yolk-sdk/emulators/gateway'
|
|
2124
|
+
import { startFetchHandlerServer } from '@yolk-sdk/emulators/node'
|
|
2125
|
+
|
|
2126
|
+
const server = await startFetchHandlerServer(makeGatewayEmulator().fetch)
|
|
2127
|
+
// ... point the code under test at server.url ...
|
|
2128
|
+
await server.close()
|
|
2129
|
+
```
|
|
2130
|
+
|
|
2131
|
+
## License
|
|
2132
|
+
|
|
2133
|
+
`@yolk-sdk/emulators` is MIT. The Fortnox, Microsoft, Dropbox, Notion, Todoist, Telegram, GitHub,
|
|
2134
|
+
Google, LinkedIn search, and MCP emulators depend on (does not vendor or bundle) the Apache-2.0
|
|
2135
|
+
[`@emulators/core`](https://github.com/vercel-labs/emulate) package, which ships no `NOTICE` file.
|