@yolk-sdk/emulators 0.1.0-canary.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,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
|
+
}
|