@yolk-sdk/emulators 0.1.0-canary.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * LinkedIn search emulator state: the Exa people searches and the Enrich Layer profiles the state
3
+ * answers, the absent profiles, and the digests of the API keys a seed marks as rejected on each
4
+ * origin, with the seed input and the default seed (internal; re-exported by
5
+ * `src/linkedin-search.ts`).
6
+ *
7
+ * The default entities are the synthetic LinkedIn search conformance fixtures, copied as data
8
+ * (the seeded query with the three answers the fixtures record for `numResults` 10, 3, and 2, the
9
+ * seeded profile with its profile and email answers, and the seeded absent profile), never
10
+ * imported from SDK code. The default rejected keys are the synthetic invalid keys the two
11
+ * unauthorized cases send (no account data). Every rendered answer keeps the fixtures' key order.
12
+ *
13
+ * Rejected keys are never kept: the state holds only their per-origin digests (SHA-256 of the
14
+ * origin, a space, and the key), so a rejected key that a request carries as its bearer reaches
15
+ * neither the state nor `/_emulate/*`. There are no writes, cursors, minted ids, or clock reads.
16
+ *
17
+ * @experimental
18
+ */
19
+ import { createHash } from 'node:crypto'
20
+ import { Match, Predicate, Result, type SchemaIssue } from 'effect'
21
+ import * as Schema from 'effect/Schema'
22
+ import { isRecognisableBearerValue } from '../stateful-secrets.ts'
23
+
24
+ /** The origin every Exa fixture records; another origin is not emulated. */
25
+ export const linkedInSearchEmulatorExaOrigin = 'https://api.exa.ai'
26
+
27
+ /** The origin every Enrich Layer fixture records (its API base is `/api/v2`). */
28
+ export const linkedInSearchEmulatorEnrichLayerOrigin = 'https://enrichlayer.com'
29
+
30
+ /**
31
+ * The per-origin digest of an API key: SHA-256 (hex) of the origin, a space, and the key. Neither
32
+ * an origin nor a recognisable bearer holds a space, so distinct pairs never share a text.
33
+ */
34
+ export const linkedInSearchKeyDigest = (key: string, origin: string): string =>
35
+ createHash('sha256').update(`${origin} ${key}`, 'utf8').digest('hex')
36
+
37
+ /** A search query as the cases seed it: trimmed, non-empty, one line, at most 500 characters. */
38
+ export const linkedInSearchQueryPattern = /^\S(?:.*\S)?$/
39
+
40
+ /** The profile URL form the emulator takes: `https://<host>/in/<slug>`. */
41
+ export const linkedInSearchProfileUrlPattern = /^https:\/\/[A-Za-z0-9.-]+\/in\/[^\s?#]+$/
42
+
43
+ /** The largest `numResults` the emulator takes. */
44
+ export const linkedInSearchMaxNumResults = 100
45
+
46
+ const SearchQuery = Schema.String.check(
47
+ Schema.isPattern(linkedInSearchQueryPattern),
48
+ Schema.isMaxLength(500)
49
+ )
50
+
51
+ const ProfileUrl = Schema.String.check(
52
+ Schema.isPattern(linkedInSearchProfileUrlPattern),
53
+ Schema.isMaxLength(2048)
54
+ )
55
+
56
+ const NumResults = Schema.Int.check(
57
+ Schema.isGreaterThanOrEqualTo(1),
58
+ Schema.isLessThanOrEqualTo(linkedInSearchMaxNumResults)
59
+ )
60
+
61
+ /**
62
+ * An API key a seed marks as rejected (checked to be a recognisable fail-closed bearer value after
63
+ * decoding, with a constant reason: a key is a credential, so no reason quotes it).
64
+ */
65
+ const RejectedKey = Schema.String
66
+
67
+ /** A SHA-256 digest (64 lower-case hex digits). */
68
+ const Digest = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))
69
+
70
+ /** One Exa result, in the fixtures' fields (each a string, or absent; never null). */
71
+ export const LinkedInSearchEmulatorResult = Schema.Struct({
72
+ title: Schema.optionalKey(Schema.String),
73
+ url: Schema.optionalKey(Schema.String),
74
+ author: Schema.optionalKey(Schema.String),
75
+ publishedDate: Schema.optionalKey(Schema.String),
76
+ text: Schema.optionalKey(Schema.String)
77
+ })
78
+
79
+ export type LinkedInSearchEmulatorResult = typeof LinkedInSearchEmulatorResult.Type
80
+
81
+ /**
82
+ * An Exa people search the state answers: the query, the `numResults` it was asked with, and the
83
+ * results answered (1 to `numResults`; no fixture records an empty search).
84
+ */
85
+ export const LinkedInSearchEmulatorSearch = Schema.Struct({
86
+ query: SearchQuery,
87
+ numResults: NumResults,
88
+ results: Schema.Array(LinkedInSearchEmulatorResult)
89
+ })
90
+
91
+ export type LinkedInSearchEmulatorSearch = typeof LinkedInSearchEmulatorSearch.Type
92
+
93
+ /**
94
+ * An Enrich Layer profile the state answers, in the profile fixture's fields, and the email its
95
+ * lookup answers (absent: an email lookup of this profile is not emulated).
96
+ */
97
+ export const LinkedInSearchEmulatorProfile = Schema.Struct({
98
+ url: ProfileUrl,
99
+ publicIdentifier: Schema.String,
100
+ fullName: Schema.String,
101
+ headline: Schema.String,
102
+ email: Schema.optionalKey(Schema.String)
103
+ })
104
+
105
+ export type LinkedInSearchEmulatorProfile = typeof LinkedInSearchEmulatorProfile.Type
106
+
107
+ /** The whole emulator state (JSON-compatible; what `snapshot()` returns). */
108
+ export const LinkedInSearchEmulatorStateSchema = Schema.Struct({
109
+ /** Digests (for the Exa origin) of the keys Exa answers with the recorded 401. */
110
+ exaRejectedKeyDigests: Schema.Array(Digest),
111
+ searches: Schema.Array(LinkedInSearchEmulatorSearch),
112
+ /** Digests (for the Enrich Layer origin) of the keys Enrich Layer answers with the 401. */
113
+ enrichLayerRejectedKeyDigests: Schema.Array(Digest),
114
+ profiles: Schema.Array(LinkedInSearchEmulatorProfile),
115
+ /** Profile URLs that name no profile: their profile lookup answers the recorded 404. */
116
+ absentProfileUrls: Schema.Array(ProfileUrl)
117
+ })
118
+
119
+ export type LinkedInSearchEmulatorState = typeof LinkedInSearchEmulatorStateSchema.Type
120
+
121
+ /**
122
+ * A typed seed: every key, when given, replaces that part of the default seed (the fixture
123
+ * entities). Rejected keys are given as keys and kept only as their per-origin digests.
124
+ */
125
+ export const LinkedInSearchEmulatorSeed = Schema.Struct({
126
+ exaRejectedKeys: Schema.optionalKey(Schema.Array(RejectedKey)),
127
+ searches: Schema.optionalKey(Schema.Array(LinkedInSearchEmulatorSearch)),
128
+ enrichLayerRejectedKeys: Schema.optionalKey(Schema.Array(RejectedKey)),
129
+ profiles: Schema.optionalKey(Schema.Array(LinkedInSearchEmulatorProfile)),
130
+ absentProfileUrls: Schema.optionalKey(Schema.Array(ProfileUrl))
131
+ })
132
+
133
+ export type LinkedInSearchEmulatorSeed = typeof LinkedInSearchEmulatorSeed.Type
134
+
135
+ const strict = { onExcessProperty: 'error' } as const
136
+
137
+ const decodeSeedInput = Schema.decodeUnknownResult(LinkedInSearchEmulatorSeed, strict)
138
+
139
+ const decodeStateInput = Schema.decodeUnknownResult(LinkedInSearchEmulatorStateSchema, strict)
140
+
141
+ /** Every field name the seed and state schemas define (the only names a seed error may print). */
142
+ const knownFieldNames: ReadonlySet<string> = new Set([
143
+ ...Object.keys(LinkedInSearchEmulatorSeed.fields),
144
+ ...Object.keys(LinkedInSearchEmulatorStateSchema.fields),
145
+ ...Object.keys(LinkedInSearchEmulatorSearch.fields),
146
+ ...Object.keys(LinkedInSearchEmulatorResult.fields),
147
+ ...Object.keys(LinkedInSearchEmulatorProfile.fields)
148
+ ])
149
+
150
+ /**
151
+ * A field path in constant text: array indices and the schemas' own field names as written, any
152
+ * other key (one a seed made up, which may be anything, a key included) as `<unexpected key>`.
153
+ */
154
+ const fieldPath = (path: ReadonlyArray<PropertyKey>): string => {
155
+ const text = path
156
+ .map(segment =>
157
+ Predicate.isNumber(segment)
158
+ ? `[${segment}]`
159
+ : Predicate.isString(segment) && knownFieldNames.has(segment)
160
+ ? `.${segment}`
161
+ : '.<unexpected key>'
162
+ )
163
+ .join('')
164
+ .replace(/^\./, '')
165
+
166
+ return text === '' ? 'the seed root' : text
167
+ }
168
+
169
+ /** The category of a leaf issue, in constant text. */
170
+ const issueCategory = (tag: SchemaIssue.Issue['_tag']): string => {
171
+ switch (tag) {
172
+ case 'MissingKey':
173
+ return 'missing key'
174
+ case 'UnexpectedKey':
175
+ return 'unexpected key'
176
+ case 'InvalidType':
177
+ return 'invalid type'
178
+ case 'AnyOf':
179
+ case 'OneOf':
180
+ return 'no matching member'
181
+ case 'Forbidden':
182
+ return 'forbidden value'
183
+ default:
184
+ return 'invalid value'
185
+ }
186
+ }
187
+
188
+ type Leaf = { readonly issue: SchemaIssue.Issue; readonly path: ReadonlyArray<PropertyKey> }
189
+
190
+ /** The first leaf issue under `issue`, with its full path. */
191
+ const firstLeaf = (issue: SchemaIssue.Issue, path: ReadonlyArray<PropertyKey>): Leaf =>
192
+ Match.value(issue).pipe(
193
+ Match.tag('Pointer', pointer => firstLeaf(pointer.issue, [...path, ...pointer.path])),
194
+ Match.tag('Filter', filter => firstLeaf(filter.issue, path)),
195
+ Match.tag('Encoding', encoding => firstLeaf(encoding.issue, path)),
196
+ Match.tag('Composite', composite => firstLeaf(composite.issues[0], path)),
197
+ Match.tag('AnyOf', anyOf => {
198
+ const [first] = anyOf.issues
199
+
200
+ return first === undefined ? { issue: anyOf, path } : firstLeaf(first, path)
201
+ }),
202
+ Match.orElse(leaf => ({ issue: leaf, path }))
203
+ )
204
+
205
+ /**
206
+ * Why a seed does not decode, in constant text only: the category of the first issue and its
207
+ * field path, never the input (Effect's own messages quote input values, an unexpected key's value
208
+ * included, and a seed may carry keys). An unexpected key is reported at its parent's path.
209
+ */
210
+ const issueReason = (root: SchemaIssue.Issue): string => {
211
+ const { issue, path } = firstLeaf(root, [])
212
+
213
+ // The unexpected key itself is input: report it at its parent.
214
+ const where = Predicate.isTagged(issue, 'UnexpectedKey') ? path.slice(0, -1) : path
215
+
216
+ return `${issueCategory(issue._tag)} at ${fieldPath(where)}`
217
+ }
218
+
219
+ // Default entities, copied from the fixtures.
220
+
221
+ const seededQuery = 'synthetic conformance engineer'
222
+
223
+ const person01: LinkedInSearchEmulatorResult = {
224
+ title: 'Synthetic Person 01 | Conformance Engineer at Example Synthetic Co',
225
+ url: 'https://linkedin.example.com/in/synthetic-person-01',
226
+ author: 'Synthetic Person 01',
227
+ text: 'Synthetic Person 01. Conformance Engineer at Example Synthetic Co. Synthetic profile text, not a real person.'
228
+ }
229
+
230
+ const person02: LinkedInSearchEmulatorResult = {
231
+ title: 'Synthetic Person 02 | Test Engineer at Example Synthetic Co',
232
+ url: 'https://linkedin.example.com/in/synthetic-person-02',
233
+ author: 'Synthetic Person 02',
234
+ publishedDate: '2026-01-15T00:00:00.000Z',
235
+ text: 'Synthetic Person 02. Test Engineer at Example Synthetic Co. Synthetic profile text, not a real person.'
236
+ }
237
+
238
+ const person03: LinkedInSearchEmulatorResult = {
239
+ title: 'Synthetic Person 03 | Conformance Lead at Example Synthetic Labs',
240
+ url: 'https://linkedin.example.com/in/synthetic-person-03'
241
+ }
242
+
243
+ /** The limited search's second result: the fixture records it without `publishedDate`. */
244
+ const { publishedDate: _limitedOmits, ...person02Limited } = person02
245
+
246
+ const defaultSearches: ReadonlyArray<LinkedInSearchEmulatorSearch> = [
247
+ // The people-results fixture (the connector's default numResults).
248
+ { query: seededQuery, numResults: 10, results: [person01, person02, person03] },
249
+ // The num-results-limit fixture: the control, then the limited search.
250
+ { query: seededQuery, numResults: 3, results: [person01, person02, person03] },
251
+ { query: seededQuery, numResults: 2, results: [person01, person02Limited] }
252
+ ]
253
+
254
+ const defaultProfiles: ReadonlyArray<LinkedInSearchEmulatorProfile> = [
255
+ {
256
+ url: 'https://linkedin.example.com/in/synthetic-person-01',
257
+ publicIdentifier: 'synthetic-person-01',
258
+ fullName: 'Synthetic Person 01',
259
+ headline: 'Conformance Engineer at Example Synthetic Co',
260
+ email: 'synthetic-person-01@example.com'
261
+ }
262
+ ]
263
+
264
+ const defaultAbsentProfileUrls: ReadonlyArray<string> = [
265
+ 'https://linkedin.example.com/in/synthetic-absent-person-00'
266
+ ]
267
+
268
+ /** The synthetic invalid keys the unauthorized cases send (never account data). */
269
+ export const linkedInSearchEmulatorDefaultRejectedKeys = {
270
+ exa: ['yolk-conformance-invalid-exa-key'],
271
+ enrichLayer: ['yolk-conformance-invalid-enrich-layer-key']
272
+ } as const
273
+
274
+ type SeedParts = {
275
+ readonly exaRejectedKeys: ReadonlyArray<string>
276
+ readonly searches: ReadonlyArray<LinkedInSearchEmulatorSearch>
277
+ readonly enrichLayerRejectedKeys: ReadonlyArray<string>
278
+ readonly profiles: ReadonlyArray<LinkedInSearchEmulatorProfile>
279
+ readonly absentProfileUrls: ReadonlyArray<string>
280
+ }
281
+
282
+ /** The index of the first value equal to an earlier one, or `-1`. */
283
+ const repeatIndex = <A>(values: ReadonlyArray<A>): number =>
284
+ values.findIndex((value, index) => values.indexOf(value) !== index)
285
+
286
+ /**
287
+ * Integrity problems a decoded seed can still have, in constant text only (a category and a field
288
+ * path, never a seeded value: a seed may carry keys anywhere).
289
+ */
290
+ const seedProblem = (parts: SeedParts): string | undefined => {
291
+ const search = repeatIndex(
292
+ parts.searches.map(entry => JSON.stringify([entry.query, entry.numResults]))
293
+ )
294
+
295
+ if (search !== -1) return `duplicate search at searches[${search}]`
296
+
297
+ // No fixture records an empty search, and Exa answers at most numResults results.
298
+ const results = parts.searches.findIndex(
299
+ entry => entry.results.length === 0 || entry.results.length > entry.numResults
300
+ )
301
+
302
+ if (results !== -1) return `not 1 to numResults results at searches[${results}].results`
303
+
304
+ const urls = [...parts.profiles.map(profile => profile.url), ...parts.absentProfileUrls]
305
+ const url = repeatIndex(urls)
306
+
307
+ if (url !== -1) {
308
+ return url < parts.profiles.length
309
+ ? `duplicate profile URL at profiles[${url}].url`
310
+ : `duplicate profile URL at absentProfileUrls[${url - parts.profiles.length}]`
311
+ }
312
+
313
+ for (const [field, keys] of [
314
+ ['exaRejectedKeys', parts.exaRejectedKeys],
315
+ ['enrichLayerRejectedKeys', parts.enrichLayerRejectedKeys]
316
+ ] as const) {
317
+ const unrecognisable = keys.findIndex(key => !isRecognisableBearerValue(key))
318
+
319
+ if (unrecognisable !== -1) {
320
+ return `not a recognisable bearer value at ${field}[${unrecognisable}]`
321
+ }
322
+
323
+ const repeated = repeatIndex(keys)
324
+
325
+ if (repeated !== -1) return `duplicate key at ${field}[${repeated}]`
326
+ }
327
+
328
+ return undefined
329
+ }
330
+
331
+ const stateFromSeed = (seed: LinkedInSearchEmulatorSeed): LinkedInSearchEmulatorState | string => {
332
+ const parts: SeedParts = {
333
+ exaRejectedKeys: seed.exaRejectedKeys ?? linkedInSearchEmulatorDefaultRejectedKeys.exa,
334
+ searches: seed.searches ?? defaultSearches,
335
+ enrichLayerRejectedKeys:
336
+ seed.enrichLayerRejectedKeys ?? linkedInSearchEmulatorDefaultRejectedKeys.enrichLayer,
337
+ profiles: seed.profiles ?? defaultProfiles,
338
+ absentProfileUrls: seed.absentProfileUrls ?? defaultAbsentProfileUrls
339
+ }
340
+
341
+ return (
342
+ seedProblem(parts) ?? {
343
+ exaRejectedKeyDigests: parts.exaRejectedKeys.map(key =>
344
+ linkedInSearchKeyDigest(key, linkedInSearchEmulatorExaOrigin)
345
+ ),
346
+ searches: parts.searches,
347
+ enrichLayerRejectedKeyDigests: parts.enrichLayerRejectedKeys.map(key =>
348
+ linkedInSearchKeyDigest(key, linkedInSearchEmulatorEnrichLayerOrigin)
349
+ ),
350
+ profiles: parts.profiles,
351
+ absentProfileUrls: parts.absentProfileUrls
352
+ }
353
+ )
354
+ }
355
+
356
+ /**
357
+ * Decode and build a seed; a string is the reason it is invalid, in constant text only (a
358
+ * category and a field path; never an input value or a key the seed made up).
359
+ */
360
+ export const buildSeedState = (input: unknown): LinkedInSearchEmulatorState | string => {
361
+ const decoded = decodeSeedInput(input)
362
+
363
+ return Result.isFailure(decoded)
364
+ ? issueReason(decoded.failure.issue)
365
+ : stateFromSeed(decoded.success)
366
+ }
367
+
368
+ /** Decode a full state (a restored snapshot); a string is the reason it is invalid (constant). */
369
+ export const decodeState = (input: unknown): LinkedInSearchEmulatorState | string => {
370
+ const decoded = decodeStateInput(input)
371
+
372
+ return Result.isFailure(decoded) ? issueReason(decoded.failure.issue) : decoded.success
373
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Stateful LinkedIn search emulator (the Exa and Enrich Layer routes of the LinkedIn search
3
+ * conformance cases: `POST /search` on `https://api.exa.ai`, and `GET /api/v2/profile` and
4
+ * `GET /api/v2/profile/email` on `https://enrichlayer.com`), built on the upstream
5
+ * `@emulators/core` custom runtime, with a request ledger, status faults, and an `/_emulate/*`
6
+ * control plane.
7
+ *
8
+ * It never imports SDK code: its answers, error bodies, and default seed are copied as data from
9
+ * the synthetic LinkedIn search conformance fixtures, and every route names the conformance cases
10
+ * it follows in `linkedInSearchEmulatorRoutes`. Only the routes those seven cases send are
11
+ * emulated, and every one is a read (nothing is ever written). Response behaviour comes only from
12
+ * the fixtures, byte for byte: a search answers only the results the state holds for exactly its
13
+ * query and `numResults` (the default seed holds the three the fixtures record, for `numResults`
14
+ * 10, 3, and 2); a profile lookup answers a held profile, or the recorded 404 for a seeded absent
15
+ * profile; an email lookup answers a held profile's recorded email. A key a seed marks as rejected
16
+ * on an origin answers that origin's recorded 401 (the default seed rejects the synthetic invalid
17
+ * keys the two unauthorized cases send). Anything else answers one ledgered 400 not-emulated and
18
+ * uses up no fault. Every route answers only on its recorded origin (`fetchOn(origin)` serves one
19
+ * behind a loopback rewrite).
20
+ *
21
+ * Fail closed, on the shared wrapper's fail-closed mode exactly as `/github` and `/google` use it:
22
+ * a request is recognised only when its raw path is exactly an emulated route path under that
23
+ * route's method, and any `Authorization` header is exactly `Bearer <token>` with a recognisable
24
+ * bearer (the RFC 6750 `b64token` syntax, at least 8 characters, a first character in
25
+ * `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, and a character outside `[0-9.eE+-]`). Every
26
+ * other request is ledgered and answered with constant text only (`/<unrecognised>`, a standard
27
+ * method or `<other>`, an empty query, no body, a constant reason). A recognised request that
28
+ * repeats the bearer anywhere (its raw path, the raw query or any query key or value, the recorded
29
+ * `content-type` header, or its body, through the wrapper's fixpoint closure of tolerant
30
+ * percent-decoding and JSON-unescaping, capped, where a cap refuses) is ledgered with constant text
31
+ * only; any other has the bearer scrubbed from every ledgered field and reason. The bearer is never
32
+ * stored, forwarded, or ledgered: routes see only its per-origin digest (the wrapper's opt-in
33
+ * `bearerDigest`; SHA-256 of the origin, a space, and the key), which a plan compares with the
34
+ * digests of the seed's rejected keys for that origin; the state holds only those digests, so a key
35
+ * a request carries as its bearer, a rejected one included, never reaches the state or
36
+ * `/_emulate/*` (a key sent as data elsewhere in a request is ledgered like any other text), and
37
+ * seed errors are constant text (a category and a field path) that never quote one. Refusals never
38
+ * echo a request's own query or body keys (the wrapper's constant-reason `exactQuery` and
39
+ * `exactBodyKeys`).
40
+ *
41
+ * Request-shape latitude (`/linkedin-search`, the only accepted deviations): any bearer value in
42
+ * the RFC 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with
43
+ * a character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
44
+ * `[0-9.eE+-]`, that occurs nowhere else in the request (never stored or ledgered; only its
45
+ * per-origin digest is compared, with the digests of the keys the seed marks as rejected on that
46
+ * origin); extra request headers; JSON key order; `content-type` media-type parameters on the
47
+ * search; any percent-encoding of the `linkedin_profile_url` value that decodes once to the same
48
+ * profile URL; and, with a key the seed marks as rejected on the request's origin, any search
49
+ * `query` (one trimmed line of at most 500 characters) with any integer `numResults` from 1 to 100,
50
+ * and any profile URL of the form `https://<host>/in/<slug>`, each answered the origin's
51
+ * recorded 401. `Authorization` must be exactly `Bearer <token>` (that spelling, one space).
52
+ * Everything else (other body keys or values, a `category` other than `people`, a `type` other than
53
+ * `auto`, `contents` other than `{ "text": true }`, a search the state holds no answer for (another
54
+ * query, or a `numResults` no seeded search of that query records), a profile URL the state holds
55
+ * neither as a profile nor as absent, an email lookup of an absent profile, any query parameter on
56
+ * the search, other, missing, or repeated query parameters on a lookup, a query parameter name in
57
+ * any but its plain form (such as `%6cinkedin_profile_url`), empty query components such as a bare
58
+ * `?` or a stray `&`, a body on a lookup, another origin, and a bearer repeated anywhere in the
59
+ * request) is not emulated.
60
+ *
61
+ * Node-only: `@emulators/core` imports Node builtins, so the core is loaded lazily by
62
+ * `makeLinkedInSearchEmulator` (importing this module has no side effects).
63
+ *
64
+ * @experimental
65
+ */
66
+ import { Data, Predicate } from 'effect'
67
+ import {
68
+ linkedInSearchApiRoutes,
69
+ linkedInSearchEmulatorDrillKnobs,
70
+ type LinkedInSearchApiEnv,
71
+ type LinkedInSearchEmulatorDrills
72
+ } from './linkedin-search/api.ts'
73
+ import {
74
+ buildSeedState,
75
+ decodeState,
76
+ linkedInSearchKeyDigest,
77
+ type LinkedInSearchEmulatorSeed,
78
+ type LinkedInSearchEmulatorState
79
+ } from './linkedin-search/state.ts'
80
+ import type { EmulatorRouteEvidence } from './route-evidence.ts'
81
+ import {
82
+ StatefulFault,
83
+ StatefulFaultMatch,
84
+ checkBooleanDrills,
85
+ makeStatefulEmulator,
86
+ routeEvidence,
87
+ type StatefulCoverage,
88
+ type StatefulEmulatorApi,
89
+ type StatefulFaultState,
90
+ type StatefulInputKind,
91
+ type StatefulLedgerEntry
92
+ } from './stateful-emulator.ts'
93
+ import { statefulCoreRuntime } from './stateful-core.ts'
94
+
95
+ export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
96
+
97
+ export { emulatorEvidenceHeader } from './route-evidence.ts'
98
+
99
+ export {
100
+ linkedInSearchEmulatorErrorBodies,
101
+ type LinkedInSearchEmulatorDrills
102
+ } from './linkedin-search/api.ts'
103
+
104
+ export {
105
+ LinkedInSearchEmulatorProfile,
106
+ LinkedInSearchEmulatorResult,
107
+ LinkedInSearchEmulatorSearch,
108
+ LinkedInSearchEmulatorSeed,
109
+ LinkedInSearchEmulatorStateSchema,
110
+ linkedInSearchEmulatorEnrichLayerOrigin,
111
+ linkedInSearchEmulatorExaOrigin,
112
+ type LinkedInSearchEmulatorState
113
+ } from './linkedin-search/state.ts'
114
+
115
+ /**
116
+ * Route evidence manifest: every emulated route (all reads) and the conformance cases whose
117
+ * (currently synthetic, unverified) wire claims it follows. Kept in sync with the handlers by
118
+ * construction (both come from one route table).
119
+ */
120
+ export const linkedInSearchEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> =
121
+ linkedInSearchApiRoutes.map(routeEvidence)
122
+
123
+ /**
124
+ * Optional fault filter; an omitted field matches every request. `method` is the HTTP method;
125
+ * `path` is the raw request path (ending in `*`, a prefix); `route` is a route template of the
126
+ * manifest (another value is rejected when the fault is added).
127
+ */
128
+ export const LinkedInSearchFaultMatch = StatefulFaultMatch
129
+
130
+ export type LinkedInSearchFaultMatch = typeof StatefulFaultMatch.Type
131
+
132
+ /**
133
+ * A status fault (400-599): answer matching requests with this status, headers, and body instead
134
+ * of the route's answer. The body defaults to `{ error: { type: 'emulator_fault', message } }`;
135
+ * `count` limits how many requests it answers. Only a request the emulator would answer reaches a
136
+ * fault: a request that is not emulated, by its shape or by the state, never uses one up.
137
+ */
138
+ export const LinkedInSearchFault = StatefulFault
139
+
140
+ export type LinkedInSearchFault = StatefulFault
141
+
142
+ export type LinkedInSearchFaultState = StatefulFaultState
143
+
144
+ export type LinkedInSearchLedgerEntry = StatefulLedgerEntry
145
+
146
+ export type LinkedInSearchCoverage = StatefulCoverage
147
+
148
+ /** Invalid emulator input from the JS API: a seed, a fault, or an option. A programmer error. */
149
+ export class LinkedInSearchEmulatorInputInvalid extends Data.TaggedError(
150
+ 'LinkedInSearchEmulatorInputInvalid'
151
+ )<{
152
+ readonly input: StatefulInputKind
153
+ readonly reason: string
154
+ }> {
155
+ override get message(): string {
156
+ return `Invalid LinkedIn search emulator ${this.input}: ${this.reason}`
157
+ }
158
+ }
159
+
160
+ export type LinkedInSearchEmulatorOptions = {
161
+ /** Typed seed; defaults to the fixture entities. */
162
+ readonly seed?: LinkedInSearchEmulatorSeed
163
+ /** Drill knobs (tests only): make the emulator disagree with one conformance claim. */
164
+ readonly drills?: LinkedInSearchEmulatorDrills
165
+ }
166
+
167
+ export type LinkedInSearchEmulator = StatefulEmulatorApi<
168
+ LinkedInSearchEmulatorState,
169
+ LinkedInSearchEmulatorSeed
170
+ >
171
+
172
+ const inputInvalid = (input: StatefulInputKind, reason: string) =>
173
+ new LinkedInSearchEmulatorInputInvalid({ input, reason })
174
+
175
+ /** The constant reason of a request on no emulated route shape. */
176
+ const unrecognisedReason = 'no emulated Exa or Enrich Layer route for this method and path'
177
+
178
+ /** The constant reason of a request whose `Authorization` header is not one recognisable bearer. */
179
+ const unrecognisedAuthorizationReason = 'an unrecognisable Authorization header is not emulated'
180
+
181
+ /**
182
+ * Create a stateful LinkedIn search emulator on the `@emulators/core` custom runtime. Each call
183
+ * has its own state, ledger, and faults. Rejects with `LinkedInSearchEmulatorInputInvalid` for an
184
+ * invalid seed or option. See `src/stateful-emulator.ts` for the request precedence.
185
+ */
186
+ export const makeLinkedInSearchEmulator = async (
187
+ options: LinkedInSearchEmulatorOptions = {}
188
+ ): Promise<LinkedInSearchEmulator> => {
189
+ const initial = buildSeedState(options.seed ?? {})
190
+
191
+ if (Predicate.isString(initial)) {
192
+ throw inputInvalid('seed', initial)
193
+ }
194
+
195
+ checkBooleanDrills(options.drills, linkedInSearchEmulatorDrillKnobs, inputInvalid)
196
+
197
+ const drills = options.drills ?? {}
198
+
199
+ const env: LinkedInSearchApiEnv = {
200
+ drills: {
201
+ defaultSearchWithoutText: drills.defaultSearchWithoutText === true,
202
+ numResultsIgnored: drills.numResultsIgnored === true,
203
+ profileAnswersEmptyObject: drills.profileAnswersEmptyObject === true,
204
+ emailAnswerOmitsEmail: drills.emailAnswerOmitsEmail === true,
205
+ exaUnauthorizedAs5xx: drills.exaUnauthorizedAs5xx === true,
206
+ enrichLayerUnauthorizedAs2xx: drills.enrichLayerUnauthorizedAs2xx === true,
207
+ absentProfileAs2xx: drills.absentProfileAs2xx === true
208
+ }
209
+ }
210
+
211
+ return makeStatefulEmulator<
212
+ LinkedInSearchEmulatorState,
213
+ LinkedInSearchApiEnv,
214
+ LinkedInSearchEmulatorSeed
215
+ >(
216
+ {
217
+ routes: linkedInSearchApiRoutes,
218
+ env,
219
+ initial,
220
+ buildSeed: buildSeedState,
221
+ recordHeaders: [{ name: 'content-type', json: false }],
222
+ failClosed: {
223
+ unrecognised: unrecognisedReason,
224
+ unrecognisedAuthorization: unrecognisedAuthorizationReason
225
+ },
226
+ bearerDigest: linkedInSearchKeyDigest,
227
+ // No cursors or other runtime data.
228
+ clearRuntime: () => undefined,
229
+ runtimeState: () => ({}),
230
+ seedSummary: state => ({
231
+ searches: state.searches.length,
232
+ profiles: state.profiles.length,
233
+ absentProfiles: state.absentProfileUrls.length,
234
+ exaRejectedKeys: state.exaRejectedKeyDigests.length,
235
+ enrichLayerRejectedKeys: state.enrichLayerRejectedKeyDigests.length
236
+ }),
237
+ inputInvalid
238
+ },
239
+ statefulCoreRuntime({ name: 'linkedin-search', initial, decodeState, inputInvalid })
240
+ )
241
+ }