@yolk-sdk/emulators 0.1.0-canary.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
package/src/fortnox.ts ADDED
@@ -0,0 +1,801 @@
1
+ /**
2
+ * Stateful Fortnox API emulator (`/3`), built on the upstream `@emulators/core` custom runtime,
3
+ * with a request ledger, wire faults, and an `/_emulate/*` control plane.
4
+ *
5
+ * It never imports SDK code: its wire shapes and default seed are copied as data from the
6
+ * synthetic Fortnox conformance fixtures, and every route names the conformance cases it follows
7
+ * in `fortnoxEmulatorRoutes`. The observed Fortnox quirks those cases claim (sticky row discounts,
8
+ * empty strings that keep values, payment filters that skip unbooked invoices, `ErrorInformation`
9
+ * rejections, read-only `Country`) are implemented and listed in `fortnoxEmulatorQuirks`.
10
+ *
11
+ * Node-only: `@emulators/core` imports Node builtins, so the core is loaded lazily by
12
+ * `makeFortnoxEmulator` (importing this module has no side effects).
13
+ *
14
+ * @experimental
15
+ */
16
+ import type { EmulatorSnapshot } from '@emulators/core'
17
+ import { Data, Predicate, Result } from 'effect'
18
+ import * as Schema from 'effect/Schema'
19
+ import {
20
+ EmulatorHeaderRecord,
21
+ EmulatorResponseStatus,
22
+ emulatorJobHeader,
23
+ handlerFailedHeader
24
+ } from './emulator-http.ts'
25
+ import {
26
+ errorInformation,
27
+ fortnoxApiRoutes,
28
+ fortnoxEmulatorErrorCodes,
29
+ matchFortnoxRoute,
30
+ parseJsonText,
31
+ registerFortnoxApi,
32
+ type FortnoxApiEnv,
33
+ type FortnoxEmulatorQuirks
34
+ } from './fortnox/api.ts'
35
+ import {
36
+ buildSeedState,
37
+ decodeState,
38
+ type FortnoxEmulatorSeed,
39
+ type FortnoxEmulatorState
40
+ } from './fortnox/state.ts'
41
+ import {
42
+ emulatorEvidenceHeader,
43
+ type EmulatorEvidence,
44
+ type EmulatorRouteEvidence
45
+ } from './route-evidence.ts'
46
+
47
+ export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
48
+
49
+ export { emulatorEvidenceHeader } from './route-evidence.ts'
50
+
51
+ export {
52
+ fortnoxEmulatorBasePath,
53
+ fortnoxEmulatorErrorCodes,
54
+ fortnoxEmulatorQuirks,
55
+ type FortnoxEmulatorQuirk,
56
+ type FortnoxEmulatorQuirks
57
+ } from './fortnox/api.ts'
58
+
59
+ export {
60
+ FortnoxEmulatorCompany,
61
+ FortnoxEmulatorCustomer,
62
+ FortnoxEmulatorCustomerSeed,
63
+ FortnoxEmulatorInvoice,
64
+ FortnoxEmulatorInvoiceRow,
65
+ FortnoxEmulatorInvoiceRowSeed,
66
+ FortnoxEmulatorInvoiceSeed,
67
+ FortnoxEmulatorOutboxEntry,
68
+ FortnoxEmulatorProfile,
69
+ FortnoxEmulatorSeed,
70
+ FortnoxEmulatorStateSchema,
71
+ type FortnoxEmulatorState
72
+ } from './fortnox/state.ts'
73
+
74
+ /** Origin the connector calls; also the default origin of `@url` links. */
75
+ export const fortnoxEmulatorDefaultOrigin = 'https://api.fortnox.se'
76
+
77
+ /**
78
+ * Route evidence manifest: every emulated Fortnox route, whether it writes, and the conformance
79
+ * cases whose (currently synthetic, unverified) wire claims it follows. Kept in sync with the
80
+ * handlers by construction (both come from one route table).
81
+ */
82
+ export const fortnoxEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = fortnoxApiRoutes.map(
83
+ ({ handler: _handler, queryKeys: _queryKeys, ...route }) => route
84
+ )
85
+
86
+ const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
87
+
88
+ /** Optional fault filter; an omitted field matches every request. `path` ending in `*` is a prefix. */
89
+ export const FortnoxFaultMatch = Schema.Struct({
90
+ method: Schema.optionalKey(Schema.String),
91
+ path: Schema.optionalKey(Schema.String)
92
+ })
93
+
94
+ export type FortnoxFaultMatch = typeof FortnoxFaultMatch.Type
95
+
96
+ /**
97
+ * A wire fault: answer matching requests with this status, headers, and body (for example 429
98
+ * with `retry-after`) instead of the route's successful answer, so nothing is written. A request
99
+ * the route refuses is answered by the route and uses up no fault. The body defaults to a
100
+ * Fortnox `ErrorInformation`. `count` limits how many requests it answers (omitted: all).
101
+ *
102
+ * The Gateway emulator's rules apply: statuses that cannot carry a body (1xx, 204, 205) and
103
+ * redirects (3xx) are rejected, as are invalid header names or values, a `location` header, and
104
+ * framing headers (`content-length`, `transfer-encoding`, `connection`, `keep-alive`, `upgrade`).
105
+ */
106
+ export const FortnoxFault = Schema.Struct({
107
+ kind: Schema.Literal('status'),
108
+ status: EmulatorResponseStatus,
109
+ headers: Schema.optionalKey(EmulatorHeaderRecord),
110
+ body: Schema.optionalKey(Schema.Json),
111
+ match: Schema.optionalKey(FortnoxFaultMatch),
112
+ count: Schema.optionalKey(FaultCount)
113
+ })
114
+
115
+ export type FortnoxFault = typeof FortnoxFault.Type
116
+
117
+ /** Invalid emulator input from the JS API: a seed, a fault, or an option. A programmer error. */
118
+ export class FortnoxEmulatorInputInvalid extends Data.TaggedError('FortnoxEmulatorInputInvalid')<{
119
+ readonly input: 'seed' | 'fault' | 'option'
120
+ readonly reason: string
121
+ }> {
122
+ override get message(): string {
123
+ return `Invalid Fortnox emulator ${this.input}: ${this.reason}`
124
+ }
125
+ }
126
+
127
+ export type FortnoxLedgerEntry = {
128
+ /** 1-based arrival order since the last ledger clear or reset. */
129
+ readonly seq: number
130
+ readonly method: string
131
+ /** Concrete request path, for example `/3/invoices/103`. */
132
+ readonly path: string
133
+ /** Path template of the matched route, for example `/3/invoices/{DocumentNumber}`. */
134
+ readonly route?: string
135
+ readonly query: Readonly<Record<string, string>>
136
+ /** Parsed JSON request body, when there was one. */
137
+ readonly body?: Schema.Json
138
+ readonly status: number
139
+ /** Evidence of the matched route; `unknown-route` for requests that failed closed. */
140
+ readonly evidence: EmulatorEvidence | 'unknown-route'
141
+ /** Set when a fault answered the request. */
142
+ readonly fault?: 'status'
143
+ /**
144
+ * Set when the emulator could not build or produce the response, or a route handler threw; the
145
+ * request was answered with a 500 `ErrorInformation` (still evidence-tagged) and no fault was
146
+ * used up.
147
+ */
148
+ readonly responseError?: string
149
+ }
150
+
151
+ export type FortnoxFaultState = {
152
+ readonly id: number
153
+ readonly fault: FortnoxFault
154
+ /** Remaining matching requests; `undefined` for an unlimited fault. */
155
+ readonly remaining: number | undefined
156
+ readonly applied: number
157
+ }
158
+
159
+ export type FortnoxRouteCoverage = EmulatorRouteEvidence & {
160
+ /** Ledger requests answered by this route since the last ledger clear or reset. */
161
+ readonly requests: number
162
+ }
163
+
164
+ export type FortnoxCoverage = {
165
+ readonly routes: ReadonlyArray<FortnoxRouteCoverage>
166
+ /** Ledger requests to unknown routes (failed closed). */
167
+ readonly unknownRouteRequests: number
168
+ }
169
+
170
+ export type FortnoxEmulatorOptions = {
171
+ /** Typed seed; defaults to the fixture entities (`profile: 'default'`). */
172
+ readonly seed?: FortnoxEmulatorSeed
173
+ /** Clock in epoch milliseconds. Defaults to `Date.now`. */
174
+ readonly now?: () => number
175
+ /** Drill knobs (tests only): flip one observed quirk to prove the cases catch it. */
176
+ readonly quirks?: FortnoxEmulatorQuirks
177
+ /** Origin of `@url` links in responses. Defaults to `https://api.fortnox.se`. */
178
+ readonly baseUrl?: string
179
+ }
180
+
181
+ export type FortnoxEmulator = {
182
+ /** The fetch handler (the `/3` API plus the `/_emulate/*` control plane). Never rejects. */
183
+ readonly fetch: (request: Request) => Promise<Response>
184
+ /** Origin of `@url` links in responses. */
185
+ readonly baseUrl: string
186
+ readonly ledger: {
187
+ readonly entries: () => ReadonlyArray<FortnoxLedgerEntry>
188
+ readonly clear: () => void
189
+ }
190
+ readonly faults: {
191
+ /** Add a fault; throws `FortnoxEmulatorInputInvalid` for an invalid fault. */
192
+ readonly add: (fault: FortnoxFault) => FortnoxFaultState
193
+ readonly list: () => ReadonlyArray<FortnoxFaultState>
194
+ readonly clear: () => void
195
+ }
196
+ /** Restore the current seed and clear the ledger and faults. */
197
+ readonly reset: () => Promise<void>
198
+ /**
199
+ * Replace the state with a new seed, which becomes what `reset` restores. Rejects with
200
+ * `FortnoxEmulatorInputInvalid` for an invalid seed.
201
+ */
202
+ readonly seed: (seed: FortnoxEmulatorSeed) => Promise<void>
203
+ /** A deep copy of the current state (entities, outbox, counters). */
204
+ readonly snapshot: () => FortnoxEmulatorState
205
+ readonly coverage: () => FortnoxCoverage
206
+ /** Close the core runtime. Later requests answer 503. Idempotent. */
207
+ readonly close: () => Promise<void>
208
+ }
209
+
210
+ const strict = { onExcessProperty: 'error' } as const
211
+
212
+ const decodeFault = Schema.decodeUnknownResult(FortnoxFault, strict)
213
+
214
+ const decodeFaultList = Schema.decodeUnknownResult(
215
+ Schema.Union([FortnoxFault, Schema.Struct({ faults: Schema.Array(FortnoxFault) })]),
216
+ strict
217
+ )
218
+
219
+ const issueMessage = (issue: Schema.SchemaError['issue']): string =>
220
+ new Schema.SchemaError(issue).message
221
+
222
+ const jsonResponse = (status: number, body: unknown, headers: HeadersInit = {}): Response => {
223
+ const responseHeaders = new Headers(headers)
224
+
225
+ responseHeaders.set('content-type', 'application/json')
226
+
227
+ return new Response(JSON.stringify(body), { status, headers: responseHeaders })
228
+ }
229
+
230
+ const controlError = (status: number, message: string, headers: HeadersInit = {}): Response =>
231
+ jsonResponse(status, { error: { message, type: 'emulator_error' } }, headers)
232
+
233
+ const codes = fortnoxEmulatorErrorCodes
234
+
235
+ const defaultFaultBody = (status: number): Schema.Json => ({
236
+ ErrorInformation:
237
+ status === 429
238
+ ? { error: 1, message: 'Synthetic: too many requests.', code: codes.rateLimited }
239
+ : { error: 1, message: `Synthetic upstream error (${status}).`, code: codes.upstreamError }
240
+ })
241
+
242
+ // A non-empty bearer credential. The value is never checked, stored, forwarded, or ledgered.
243
+ const bearerPattern = /^bearer\s+\S+/i
244
+
245
+ const pathMatches = (pattern: string, path: string): boolean =>
246
+ pattern.endsWith('*') ? path.startsWith(pattern.slice(0, -1)) : pattern === path
247
+
248
+ const faultMatches = (fault: FortnoxFault, method: string, path: string): boolean =>
249
+ (fault.match?.method === undefined || fault.match.method.toUpperCase() === method) &&
250
+ (fault.match?.path === undefined || pathMatches(fault.match.path, path))
251
+
252
+ const readText = (request: Request): Promise<string | undefined> =>
253
+ request.text().then(
254
+ text => text,
255
+ () => undefined
256
+ )
257
+
258
+ const validOrigin = (input: string): string | undefined => {
259
+ if (!URL.canParse(input)) {
260
+ return undefined
261
+ }
262
+
263
+ const url = new URL(input)
264
+
265
+ const bare =
266
+ (url.pathname === '/' || url.pathname === '') &&
267
+ url.search === '' &&
268
+ url.hash === '' &&
269
+ url.username === '' &&
270
+ url.password === ''
271
+
272
+ return (url.protocol === 'http:' || url.protocol === 'https:') && bare ? url.origin : undefined
273
+ }
274
+
275
+ type MutableLedgerEntry = {
276
+ seq: number
277
+ method: string
278
+ path: string
279
+ route?: string
280
+ query: Readonly<Record<string, string>>
281
+ body?: Schema.Json
282
+ status: number
283
+ evidence: EmulatorEvidence | 'unknown-route'
284
+ fault?: 'status'
285
+ responseError?: string
286
+ }
287
+
288
+ /** One core request's fault outcome: the fault's answer, set when a fault answered it. */
289
+ type FaultJob = { faultAnswer?: Response }
290
+
291
+ type MutableFaultState = {
292
+ readonly id: number
293
+ readonly fault: FortnoxFault
294
+ remaining: number | undefined
295
+ applied: number
296
+ }
297
+
298
+ const withEvidence = (response: Response, evidence: EmulatorEvidence): Response => {
299
+ const headers = new Headers(response.headers)
300
+
301
+ if (evidence === 'unverified') {
302
+ headers.set(emulatorEvidenceHeader, 'unverified')
303
+ }
304
+
305
+ return new Response(response.body, {
306
+ status: response.status,
307
+ statusText: response.statusText,
308
+ headers
309
+ })
310
+ }
311
+
312
+ const defaultQuirks: Required<FortnoxEmulatorQuirks> = {
313
+ stickyRowDiscount: true,
314
+ emptyStringClears: false,
315
+ paymentFiltersIncludeUnbooked: false
316
+ }
317
+
318
+ /** The evidence-tagged fallback when a matched API route cannot build or produce its response. */
319
+ const responseFailed = (): Response =>
320
+ errorInformation(
321
+ 500,
322
+ codes.upstreamError,
323
+ 'Synthetic: the emulator could not build the response.'
324
+ )
325
+
326
+ const isControlPath = (path: string): boolean =>
327
+ path === '/_emulate' || path.startsWith('/_emulate/')
328
+
329
+ /**
330
+ * Create a stateful Fortnox emulator on the `@emulators/core` custom runtime. Each call has its
331
+ * own state, ledger, and faults. Rejects with `FortnoxEmulatorInputInvalid` for an invalid seed
332
+ * or `baseUrl`.
333
+ *
334
+ * Requests to `/3/*` need `Authorization: Bearer <non-empty>` (missing: 401 `ErrorInformation`);
335
+ * the token is never checked, stored, forwarded to the core, or ledgered. Precedence per request:
336
+ * route match (unknown routes fail closed with a 404 `ErrorInformation`), authorization, JSON
337
+ * body parsing, then the stateful route's plan (its query allowlist, then its handler on a draft
338
+ * of the state), then the first matching fault, then the commit. A request the route refuses (any
339
+ * answer that is not 2xx, such as a 404 for an unknown id or a 400 for an invalid state) uses up
340
+ * no fault; a faulted request writes nothing. Every response from an unverified route carries
341
+ * `x-emulator-evidence: unverified`.
342
+ */
343
+ export const makeFortnoxEmulator = async (
344
+ options: FortnoxEmulatorOptions = {}
345
+ ): Promise<FortnoxEmulator> => {
346
+ const initial = buildSeedState(options.seed ?? {})
347
+
348
+ if (Predicate.isString(initial)) {
349
+ throw new FortnoxEmulatorInputInvalid({ input: 'seed', reason: initial })
350
+ }
351
+
352
+ const linkOrigin = validOrigin(options.baseUrl ?? fortnoxEmulatorDefaultOrigin)
353
+
354
+ if (linkOrigin === undefined) {
355
+ throw new FortnoxEmulatorInputInvalid({
356
+ input: 'option',
357
+ reason: 'baseUrl must be an http(s) origin without path, query, hash, or credentials'
358
+ })
359
+ }
360
+
361
+ const env: FortnoxApiEnv = {
362
+ now: options.now ?? (() => Date.now()),
363
+ linkOrigin,
364
+ quirks: { ...defaultQuirks, ...options.quirks }
365
+ }
366
+
367
+ // Loaded lazily: the core imports Node builtins and reads files at import time.
368
+ const core = await import('@emulators/core')
369
+
370
+ /**
371
+ * Fault decisions of the core requests in flight, by job id (asked by the route once it would
372
+ * answer successfully). Job ids never reset, so a ledger clear never makes two jobs share one.
373
+ */
374
+ const jobs = new Map<string, () => boolean>()
375
+ let nextJobId = 1
376
+
377
+ const definition = core.defineEmulator<FortnoxEmulatorState>({
378
+ name: 'fortnox',
379
+ cors: false,
380
+ state: () => initial,
381
+ validateSeed: value => {
382
+ const decoded = decodeState(value)
383
+
384
+ if (Predicate.isString(decoded)) {
385
+ throw new FortnoxEmulatorInputInvalid({ input: 'seed', reason: decoded })
386
+ }
387
+
388
+ return decoded
389
+ },
390
+ setup: ({ app, state }) =>
391
+ registerFortnoxApi(app, state, env, jobId =>
392
+ jobId === undefined ? false : (jobs.get(jobId)?.() ?? false)
393
+ )
394
+ })
395
+
396
+ const runtime = await core.createCustomRuntime(definition, { seed: initial })
397
+
398
+ let baseline: FortnoxEmulatorState = initial
399
+ let entries: Array<MutableLedgerEntry> = []
400
+ let faultStates: Array<MutableFaultState> = []
401
+ let nextSeq = 1
402
+ let nextFaultId = 1
403
+ let closed = false
404
+
405
+ const snapshotEntry = (entry: MutableLedgerEntry): FortnoxLedgerEntry => ({
406
+ ...entry,
407
+ query: { ...entry.query }
408
+ })
409
+
410
+ const snapshotFault = (state: MutableFaultState): FortnoxFaultState => ({ ...state })
411
+
412
+ const clearLedger = () => {
413
+ entries = []
414
+ nextSeq = 1
415
+ }
416
+
417
+ const addFault = (input: unknown): FortnoxFaultState | string => {
418
+ const decoded = decodeFault(input)
419
+
420
+ if (Result.isFailure(decoded)) {
421
+ return issueMessage(decoded.failure.issue)
422
+ }
423
+
424
+ const state: MutableFaultState = {
425
+ id: nextFaultId++,
426
+ fault: decoded.success,
427
+ remaining: decoded.success.count,
428
+ applied: 0
429
+ }
430
+
431
+ faultStates.push(state)
432
+
433
+ return snapshotFault(state)
434
+ }
435
+
436
+ const takeFault = (method: string, path: string): MutableFaultState | undefined =>
437
+ faultStates.find(
438
+ state =>
439
+ (state.remaining === undefined || state.remaining > 0) &&
440
+ faultMatches(state.fault, method, path)
441
+ )
442
+
443
+ /** Build the fault's response first: a response that cannot be built must not consume it. */
444
+ const applyFault = (state: MutableFaultState): Response => {
445
+ const headers = new Headers(state.fault.headers ?? {})
446
+ const body = state.fault.body ?? defaultFaultBody(state.fault.status)
447
+
448
+ if (!headers.has('content-type')) {
449
+ headers.set('content-type', Predicate.isString(body) ? 'text/plain' : 'application/json')
450
+ }
451
+
452
+ const response = new Response(Predicate.isString(body) ? body : JSON.stringify(body), {
453
+ status: state.fault.status,
454
+ headers
455
+ })
456
+
457
+ state.applied += 1
458
+
459
+ if (state.remaining !== undefined) {
460
+ state.remaining -= 1
461
+ }
462
+
463
+ return response
464
+ }
465
+
466
+ const snapshot = (): FortnoxEmulatorState => runtime.snapshot().state
467
+
468
+ const restore = (state: FortnoxEmulatorState): Promise<void> => {
469
+ const current: EmulatorSnapshot<FortnoxEmulatorState> = runtime.snapshot()
470
+
471
+ return runtime.restore({ ...current, state })
472
+ }
473
+
474
+ const reset = async () => {
475
+ clearLedger()
476
+ faultStates = []
477
+ await restore(baseline)
478
+ }
479
+
480
+ const reseed = async (input: unknown): Promise<FortnoxEmulatorState | string> => {
481
+ const next = buildSeedState(input)
482
+
483
+ if (Predicate.isString(next)) {
484
+ return next
485
+ }
486
+
487
+ await restore(next)
488
+ baseline = next
489
+
490
+ return next
491
+ }
492
+
493
+ const coverage = (): FortnoxCoverage => ({
494
+ routes: fortnoxEmulatorRoutes.map(route => ({
495
+ ...route,
496
+ requests: entries.filter(
497
+ entry => entry.route === route.path && entry.method.toUpperCase() === route.method
498
+ ).length
499
+ })),
500
+ unknownRouteRequests: entries.filter(entry => entry.evidence === 'unknown-route').length
501
+ })
502
+
503
+ /**
504
+ * Authorization, body parsing, then the core route (without the credential), which asks for the
505
+ * fault only once it would answer successfully.
506
+ */
507
+ const routed = async (
508
+ request: Request,
509
+ url: URL,
510
+ entry: MutableLedgerEntry
511
+ ): Promise<Response> => {
512
+ if (!bearerPattern.test(request.headers.get('authorization') ?? '')) {
513
+ return errorInformation(
514
+ 401,
515
+ codes.unauthorized,
516
+ 'Synthetic: missing or invalid authorization.'
517
+ )
518
+ }
519
+
520
+ const text = await readText(request)
521
+
522
+ if (text === undefined) {
523
+ return errorInformation(400, codes.invalidBody, 'Synthetic: the request body is unreadable.')
524
+ }
525
+
526
+ if (text !== '') {
527
+ const json = parseJsonText(text)
528
+
529
+ if (json === undefined) {
530
+ return errorInformation(
531
+ 400,
532
+ codes.invalidBody,
533
+ 'Synthetic: the request body is not valid JSON.'
534
+ )
535
+ }
536
+
537
+ entry.body = json
538
+ }
539
+
540
+ const method = request.method.toUpperCase()
541
+ const hasBody = text !== '' && method !== 'GET' && method !== 'HEAD'
542
+ const jobId = String(nextJobId++)
543
+
544
+ const headers = new Headers({ accept: 'application/json', [emulatorJobHeader]: jobId })
545
+
546
+ if (hasBody) headers.set('content-type', 'application/json')
547
+
548
+ const init: RequestInit = { method, headers }
549
+
550
+ if (hasBody) {
551
+ init.body = text
552
+ }
553
+
554
+ const job: FaultJob = {}
555
+
556
+ // Asked by the route only for an answer it would send successfully, before it commits.
557
+ jobs.set(jobId, () => {
558
+ const fault = takeFault(method, url.pathname)
559
+
560
+ if (fault === undefined) return false
561
+
562
+ try {
563
+ job.faultAnswer = applyFault(fault)
564
+ } catch (error) {
565
+ // The fault's answer could not be built (the route did not fail): the core answers its
566
+ // handler failure, and the fault is not used up.
567
+ entry.responseError = 'the emulator could not build or produce the response'
568
+
569
+ throw error
570
+ }
571
+
572
+ entry.fault = 'status'
573
+
574
+ return true
575
+ })
576
+
577
+ const response = await runtime
578
+ .fetch(new Request(new URL(`${url.pathname}${url.search}`, runtime.baseUrl), init))
579
+ .finally(() => jobs.delete(jobId))
580
+
581
+ // The fault's answer is sent from here, never through the core.
582
+ if (job.faultAnswer !== undefined) return job.faultAnswer
583
+
584
+ if (response.headers.has(handlerFailedHeader)) {
585
+ entry.responseError ??= 'the route handler failed'
586
+
587
+ return responseFailed()
588
+ }
589
+
590
+ return response
591
+ }
592
+
593
+ const emulatedApi = async (request: Request, url: URL): Promise<Response> => {
594
+ const route = matchFortnoxRoute(request.method, url.pathname)
595
+
596
+ const entry: MutableLedgerEntry = {
597
+ seq: nextSeq++,
598
+ method: request.method,
599
+ path: url.pathname,
600
+ query: Object.fromEntries(url.searchParams),
601
+ status: 0,
602
+ evidence: route?.evidence ?? 'unknown-route'
603
+ }
604
+
605
+ entries.push(entry)
606
+
607
+ if (route === undefined) {
608
+ entry.status = 404
609
+
610
+ return errorInformation(404, codes.unknownRoute, 'Synthetic: no emulated Fortnox route.')
611
+ }
612
+
613
+ entry.route = route.path
614
+
615
+ // Error recovery still answers through the route: the fallback 500 is evidence-tagged and
616
+ // the ledger records the status actually sent.
617
+ const response = await routed(request, url, entry).catch(() => {
618
+ entry.responseError = 'the emulator could not build or produce the response'
619
+
620
+ return responseFailed()
621
+ })
622
+
623
+ const tagged = withEvidence(response, route.evidence)
624
+
625
+ entry.status = tagged.status
626
+
627
+ return tagged
628
+ }
629
+
630
+ const controlPlane = async (request: Request, path: string): Promise<Response> => {
631
+ const method = request.method
632
+ const allow = (methods: string) => controlError(405, 'method not allowed', { allow: methods })
633
+
634
+ const jsonBody = async (): Promise<Schema.Json | undefined> => {
635
+ const text = await readText(request)
636
+
637
+ return text === undefined ? undefined : parseJsonText(text)
638
+ }
639
+
640
+ switch (path) {
641
+ case '/_emulate/ledger':
642
+ if (method === 'GET') {
643
+ return jsonResponse(200, { entries: entries.map(snapshotEntry) })
644
+ }
645
+
646
+ if (method === 'DELETE') {
647
+ const cleared = entries.length
648
+
649
+ clearLedger()
650
+
651
+ return jsonResponse(200, { cleared })
652
+ }
653
+
654
+ return allow('GET, DELETE')
655
+
656
+ case '/_emulate/faults': {
657
+ if (method === 'GET') {
658
+ return jsonResponse(200, { faults: faultStates.map(snapshotFault) })
659
+ }
660
+
661
+ if (method === 'DELETE') {
662
+ const cleared = faultStates.length
663
+
664
+ faultStates = []
665
+
666
+ return jsonResponse(200, { cleared })
667
+ }
668
+
669
+ if (method !== 'POST') {
670
+ return allow('GET, POST, DELETE')
671
+ }
672
+
673
+ const decoded = decodeFaultList(await jsonBody())
674
+
675
+ if (Result.isFailure(decoded)) {
676
+ return controlError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`)
677
+ }
678
+
679
+ const faults = 'faults' in decoded.success ? decoded.success.faults : [decoded.success]
680
+
681
+ return jsonResponse(201, { faults: faults.map(fault => addFault(fault)) })
682
+ }
683
+
684
+ case '/_emulate/reset':
685
+ if (method !== 'POST') {
686
+ return allow('POST')
687
+ }
688
+
689
+ await reset()
690
+
691
+ return jsonResponse(200, { reset: true })
692
+
693
+ case '/_emulate/state':
694
+ if (method !== 'GET') {
695
+ return allow('GET')
696
+ }
697
+
698
+ return jsonResponse(200, {
699
+ state: snapshot(),
700
+ faults: faultStates.map(snapshotFault),
701
+ ledgerEntries: entries.length
702
+ })
703
+
704
+ case '/_emulate/seed': {
705
+ if (method !== 'POST') {
706
+ return allow('POST')
707
+ }
708
+
709
+ const next = await reseed((await jsonBody()) ?? null)
710
+
711
+ if (Predicate.isString(next)) {
712
+ return controlError(400, `invalid seed: ${next}`)
713
+ }
714
+
715
+ return jsonResponse(200, {
716
+ seeded: true,
717
+ customers: next.customers.length,
718
+ invoices: next.invoices.length
719
+ })
720
+ }
721
+
722
+ case '/_emulate/coverage':
723
+ if (method !== 'GET') {
724
+ return allow('GET')
725
+ }
726
+
727
+ return jsonResponse(200, coverage())
728
+
729
+ default:
730
+ return controlError(404, 'unknown control-plane route')
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Last resort when handling itself fails (for example an unparseable request URL): API
736
+ * requests answer a Fortnox `ErrorInformation` 500, tagged with the matched route's evidence;
737
+ * control-plane requests answer an emulator error.
738
+ */
739
+ const lastResort = (request: Request): Response => {
740
+ const path = URL.canParse(request.url) ? new URL(request.url).pathname : undefined
741
+
742
+ if (path !== undefined && isControlPath(path)) {
743
+ return controlError(500, 'emulator failed to handle the request')
744
+ }
745
+
746
+ const route = path === undefined ? undefined : matchFortnoxRoute(request.method, path)
747
+
748
+ return route === undefined ? responseFailed() : withEvidence(responseFailed(), route.evidence)
749
+ }
750
+
751
+ const handle = async (request: Request): Promise<Response> => {
752
+ if (closed) {
753
+ return errorInformation(503, codes.upstreamError, 'Synthetic: the emulator is closed.')
754
+ }
755
+
756
+ const url = new URL(request.url)
757
+
758
+ return isControlPath(url.pathname)
759
+ ? controlPlane(request, url.pathname)
760
+ : emulatedApi(request, url)
761
+ }
762
+
763
+ return {
764
+ fetch: request => handle(request).catch(() => lastResort(request)),
765
+ baseUrl: linkOrigin,
766
+ ledger: {
767
+ entries: () => entries.map(snapshotEntry),
768
+ clear: clearLedger
769
+ },
770
+ faults: {
771
+ add: fault => {
772
+ const added = addFault(fault)
773
+
774
+ if (Predicate.isString(added)) {
775
+ throw new FortnoxEmulatorInputInvalid({ input: 'fault', reason: added })
776
+ }
777
+
778
+ return added
779
+ },
780
+ list: () => faultStates.map(snapshotFault),
781
+ clear: () => {
782
+ faultStates = []
783
+ }
784
+ },
785
+ reset,
786
+ seed: async input => {
787
+ const next = await reseed(input)
788
+
789
+ if (Predicate.isString(next)) {
790
+ throw new FortnoxEmulatorInputInvalid({ input: 'seed', reason: next })
791
+ }
792
+ },
793
+ snapshot,
794
+ coverage,
795
+ close: () => {
796
+ closed = true
797
+
798
+ return runtime.close()
799
+ }
800
+ }
801
+ }