@apifuse/provider-sdk 2.2.0-beta.3 → 2.2.0-beta.30

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 (342) hide show
  1. package/AUTHORING.md +493 -5
  2. package/CHANGELOG.md +127 -1
  3. package/README.md +52 -6
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +106 -62
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +63 -55
  8. package/bin/apifuse-pack-check.ts +22 -2
  9. package/bin/apifuse-pack-smoke.ts +78 -82
  10. package/bin/apifuse-pack-types.ts +583 -0
  11. package/bin/apifuse-perf.ts +59 -140
  12. package/bin/apifuse-record.ts +698 -113
  13. package/bin/apifuse-submit-check.ts +517 -44
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml.ts +1 -1
  18. package/dist/auth-turn/index.d.ts +4 -4
  19. package/dist/auth-turn/index.js +1 -1
  20. package/dist/auth.d.ts +16 -2
  21. package/dist/auth.js +76 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +117 -29
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/cli/templates/provider/README.md.tpl +4 -4
  32. package/dist/config/loader.d.ts +176 -17
  33. package/dist/config/loader.js +434 -161
  34. package/dist/contract-serialization.d.ts +2 -2
  35. package/dist/contract-serialization.js +7 -14
  36. package/dist/contract-types.d.ts +3 -2
  37. package/dist/contract.d.ts +3 -3
  38. package/dist/contract.js +6 -6
  39. package/dist/declaration-validation.d.ts +23 -0
  40. package/dist/declaration-validation.js +159 -0
  41. package/dist/define.d.ts +13 -1
  42. package/dist/define.js +391 -122
  43. package/dist/dev.d.ts +1 -1
  44. package/dist/dev.js +1 -1
  45. package/dist/error-resolution.d.ts +4 -0
  46. package/dist/error-resolution.js +122 -0
  47. package/dist/errors.d.ts +19 -1
  48. package/dist/errors.js +41 -3
  49. package/dist/fixture-sanitization.d.ts +26 -0
  50. package/dist/fixture-sanitization.js +216 -0
  51. package/dist/i18n/catalog.d.ts +2 -2
  52. package/dist/i18n/catalog.js +4 -10
  53. package/dist/i18n/index.d.ts +2 -2
  54. package/dist/i18n/index.js +2 -2
  55. package/dist/i18n/keys.d.ts +2 -2
  56. package/dist/index.d.ts +50 -42
  57. package/dist/index.js +41 -37
  58. package/dist/lint.d.ts +6 -1
  59. package/dist/lint.js +370 -18
  60. package/dist/native-address.d.ts +43 -0
  61. package/dist/native-address.js +281 -0
  62. package/dist/native-egress-policy.d.ts +31 -0
  63. package/dist/native-egress-policy.js +288 -0
  64. package/dist/observability.d.ts +5 -2
  65. package/dist/observability.js +48 -1
  66. package/dist/provider.d.ts +13 -11
  67. package/dist/provider.js +10 -9
  68. package/dist/public-schema-field-lint.d.ts +1 -1
  69. package/dist/recipes/gov-api.js +1 -1
  70. package/dist/runtime/auth-flow.d.ts +3 -1
  71. package/dist/runtime/auth-flow.js +8 -3
  72. package/dist/runtime/browser.d.ts +1 -1
  73. package/dist/runtime/browser.js +138 -40
  74. package/dist/runtime/cache.d.ts +2 -1
  75. package/dist/runtime/cache.js +173 -23
  76. package/dist/runtime/choice-wordlist.d.ts +9 -0
  77. package/dist/runtime/choice-wordlist.js +138 -0
  78. package/dist/runtime/choice.d.ts +13 -1
  79. package/dist/runtime/choice.js +514 -124
  80. package/dist/runtime/credential.d.ts +1 -1
  81. package/dist/runtime/credential.js +1 -1
  82. package/dist/runtime/env.d.ts +1 -1
  83. package/dist/runtime/executor.d.ts +1 -1
  84. package/dist/runtime/executor.js +25 -3
  85. package/dist/runtime/http.d.ts +3 -2
  86. package/dist/runtime/http.js +517 -55
  87. package/dist/runtime/insights.d.ts +1 -1
  88. package/dist/runtime/insights.js +6 -13
  89. package/dist/runtime/instrumentation.d.ts +2 -2
  90. package/dist/runtime/instrumentation.js +371 -23
  91. package/dist/runtime/keyring.js +1 -1
  92. package/dist/runtime/namespace.js +1 -1
  93. package/dist/runtime/native-network-errors.d.ts +33 -0
  94. package/dist/runtime/native-network-errors.js +69 -0
  95. package/dist/runtime/native-network.d.ts +96 -0
  96. package/dist/runtime/native-network.js +1232 -0
  97. package/dist/runtime/ocr.d.ts +29 -0
  98. package/dist/runtime/ocr.js +440 -0
  99. package/dist/runtime/otlp.d.ts +1 -1
  100. package/dist/runtime/perf.d.ts +1 -1
  101. package/dist/runtime/provider.d.ts +1 -1
  102. package/dist/runtime/provider.js +1 -2
  103. package/dist/runtime/proxy-errors.d.ts +1 -1
  104. package/dist/runtime/proxy-errors.js +9 -7
  105. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  106. package/dist/runtime/proxy-nodemaven.js +146 -0
  107. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  108. package/dist/runtime/proxy-retry-policy.js +2 -2
  109. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  110. package/dist/runtime/proxy-telemetry.js +58 -52
  111. package/dist/runtime/redirects.d.ts +29 -0
  112. package/dist/runtime/redirects.js +36 -0
  113. package/dist/runtime/redis.d.ts +1 -1
  114. package/dist/runtime/redis.js +5 -5
  115. package/dist/runtime/request-options.d.ts +68 -1
  116. package/dist/runtime/request-options.js +548 -0
  117. package/dist/runtime/resolver-config.d.ts +6 -0
  118. package/dist/runtime/resolver-config.js +6 -0
  119. package/dist/runtime/resolver-public.d.ts +1 -0
  120. package/dist/runtime/resolver-public.js +1 -0
  121. package/dist/runtime/resolver-shared.d.ts +3 -0
  122. package/dist/runtime/resolver-shared.js +12 -0
  123. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  124. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  125. package/dist/runtime/resolver-vendors/browser.d.ts +20 -0
  126. package/dist/runtime/resolver-vendors/browser.js +282 -0
  127. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  128. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  129. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  130. package/dist/runtime/resolver-vendors/twocaptcha.js +368 -0
  131. package/dist/runtime/resolver-vendors/types.d.ts +83 -0
  132. package/dist/runtime/resolver-vendors/types.js +69 -0
  133. package/dist/runtime/resolver.d.ts +59 -0
  134. package/dist/runtime/resolver.js +705 -0
  135. package/dist/runtime/secrets.d.ts +27 -0
  136. package/dist/runtime/secrets.js +51 -0
  137. package/dist/runtime/state.d.ts +5 -2
  138. package/dist/runtime/state.js +280 -74
  139. package/dist/runtime/stealth-cookies.d.ts +20 -0
  140. package/dist/runtime/stealth-cookies.js +111 -0
  141. package/dist/runtime/stealth.d.ts +30 -5
  142. package/dist/runtime/stealth.js +523 -259
  143. package/dist/runtime/stt.d.ts +1 -1
  144. package/dist/runtime/stt.js +12 -27
  145. package/dist/runtime/timeout.d.ts +5 -0
  146. package/dist/runtime/timeout.js +12 -0
  147. package/dist/runtime/trace.d.ts +2 -2
  148. package/dist/runtime/trace.js +2 -4
  149. package/dist/runtime/waterfall.d.ts +1 -1
  150. package/dist/schema.d.ts +1 -1
  151. package/dist/schema.js +7 -15
  152. package/dist/serve.d.ts +1 -1
  153. package/dist/serve.js +1 -1
  154. package/dist/server/index.d.ts +7 -7
  155. package/dist/server/index.js +6 -6
  156. package/dist/server/self-test-input-tokens.d.ts +2 -1
  157. package/dist/server/self-test-input-tokens.js +18 -14
  158. package/dist/server/self-test-redaction.d.ts +1 -1
  159. package/dist/server/self-test-redaction.js +1 -1
  160. package/dist/server/self-test.d.ts +117 -3
  161. package/dist/server/self-test.js +787 -151
  162. package/dist/server/serve-implementation.d.ts +210 -0
  163. package/dist/server/serve-implementation.js +2077 -0
  164. package/dist/server/serve.d.ts +1 -70
  165. package/dist/server/serve.js +1 -1143
  166. package/dist/server/types.d.ts +34 -9
  167. package/dist/server/types.js +8 -1
  168. package/dist/stateful/errors.d.ts +19 -0
  169. package/dist/stateful/errors.js +24 -0
  170. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  171. package/dist/stateful/http-provider-event-emitter.js +237 -0
  172. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  173. package/dist/stateful/http-session-owner-registry.js +210 -0
  174. package/dist/stateful/index.d.ts +18 -0
  175. package/dist/stateful/index.js +18 -0
  176. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  177. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  178. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  179. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  180. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  181. package/dist/stateful/provider-event-pipeline.js +1 -0
  182. package/dist/stateful/provider-events.d.ts +101 -0
  183. package/dist/stateful/provider-events.js +289 -0
  184. package/dist/stateful/session-key.d.ts +15 -0
  185. package/dist/stateful/session-key.js +86 -0
  186. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  187. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  188. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  189. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  190. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  191. package/dist/stateful/stateful-provider-adapter.js +287 -0
  192. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  193. package/dist/stateful/stateful-provider-observability.js +161 -0
  194. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  195. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  196. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  197. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  198. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  199. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  200. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  201. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  202. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  203. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  204. package/dist/stateful-signing.d.ts +18 -0
  205. package/dist/stateful-signing.js +27 -0
  206. package/dist/stealth/profiles.d.ts +1 -1
  207. package/dist/stealth/profiles.js +21 -21
  208. package/dist/stream-evidence.d.ts +74 -0
  209. package/dist/stream-evidence.js +785 -0
  210. package/dist/stream.d.ts +1 -1
  211. package/dist/stream.js +7 -1
  212. package/dist/testing/index.d.ts +3 -2
  213. package/dist/testing/index.js +3 -2
  214. package/dist/testing/run.d.ts +32 -2
  215. package/dist/testing/run.js +488 -28
  216. package/dist/types.d.ts +566 -19
  217. package/dist/types.js +1 -0
  218. package/dist/user-input.d.ts +30 -0
  219. package/dist/user-input.js +66 -0
  220. package/package.json +42 -7
  221. package/src/auth-turn/index.ts +2 -2
  222. package/src/auth.ts +146 -86
  223. package/src/ceremonies/index.ts +167 -92
  224. package/src/cli/commands.ts +10 -0
  225. package/src/cli/create.ts +42 -35
  226. package/src/cli/prompt-assets.ts +865 -0
  227. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  228. package/src/cli/templates/provider/README.md.tpl +4 -4
  229. package/src/config/loader.ts +667 -289
  230. package/src/contract-serialization.ts +10 -18
  231. package/src/contract-types.ts +3 -2
  232. package/src/contract.ts +14 -28
  233. package/src/declaration-validation.ts +202 -0
  234. package/src/define.ts +631 -495
  235. package/src/dev.ts +4 -9
  236. package/src/error-resolution.ts +127 -0
  237. package/src/errors.ts +56 -11
  238. package/src/fixture-sanitization.ts +247 -0
  239. package/src/i18n/catalog.ts +10 -32
  240. package/src/i18n/index.ts +2 -2
  241. package/src/i18n/keys.ts +5 -11
  242. package/src/index.ts +157 -44
  243. package/src/lint.ts +488 -154
  244. package/src/native-address.ts +340 -0
  245. package/src/native-egress-policy.ts +358 -0
  246. package/src/observability.ts +51 -1
  247. package/src/provider.ts +65 -11
  248. package/src/public-schema-field-lint.ts +7 -33
  249. package/src/recipes/gov-api.ts +2 -5
  250. package/src/runtime/auth-flow.ts +13 -7
  251. package/src/runtime/browser.ts +252 -207
  252. package/src/runtime/cache.ts +209 -81
  253. package/src/runtime/choice-wordlist.ts +145 -0
  254. package/src/runtime/choice.ts +685 -216
  255. package/src/runtime/credential.ts +2 -2
  256. package/src/runtime/env.ts +1 -1
  257. package/src/runtime/executor.ts +37 -19
  258. package/src/runtime/http.ts +645 -65
  259. package/src/runtime/insights.ts +15 -53
  260. package/src/runtime/instrumentation.ts +530 -67
  261. package/src/runtime/keyring.ts +7 -19
  262. package/src/runtime/namespace.ts +2 -7
  263. package/src/runtime/native-network-errors.ts +99 -0
  264. package/src/runtime/native-network.ts +1605 -0
  265. package/src/runtime/ocr.ts +523 -0
  266. package/src/runtime/otlp.ts +12 -23
  267. package/src/runtime/perf.ts +1 -1
  268. package/src/runtime/provider.ts +4 -9
  269. package/src/runtime/proxy-errors.ts +29 -42
  270. package/src/runtime/proxy-nodemaven.ts +221 -0
  271. package/src/runtime/proxy-retry-policy.ts +3 -3
  272. package/src/runtime/proxy-telemetry.ts +84 -77
  273. package/src/runtime/redirects.ts +66 -0
  274. package/src/runtime/redis.ts +10 -13
  275. package/src/runtime/request-options.ts +679 -9
  276. package/src/runtime/resolver-config.ts +6 -0
  277. package/src/runtime/resolver-public.ts +18 -0
  278. package/src/runtime/resolver-shared.ts +17 -0
  279. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  280. package/src/runtime/resolver-vendors/browser.ts +408 -0
  281. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  282. package/src/runtime/resolver-vendors/twocaptcha.ts +500 -0
  283. package/src/runtime/resolver-vendors/types.ts +173 -0
  284. package/src/runtime/resolver.ts +1060 -0
  285. package/src/runtime/secrets.ts +64 -0
  286. package/src/runtime/state.ts +399 -161
  287. package/src/runtime/stealth-cookies.ts +132 -0
  288. package/src/runtime/stealth.ts +681 -295
  289. package/src/runtime/stt.ts +39 -113
  290. package/src/runtime/timeout.ts +18 -0
  291. package/src/runtime/trace.ts +14 -44
  292. package/src/runtime/waterfall.ts +5 -18
  293. package/src/schema.ts +23 -84
  294. package/src/serve.ts +6 -1
  295. package/src/server/index.ts +30 -7
  296. package/src/server/self-test-input-tokens.ts +29 -14
  297. package/src/server/self-test-redaction.ts +2 -2
  298. package/src/server/self-test.ts +1030 -180
  299. package/src/server/serve-implementation.ts +3060 -0
  300. package/src/server/serve.ts +1 -1781
  301. package/src/server/types.ts +12 -13
  302. package/src/stateful/README.md +146 -0
  303. package/src/stateful/errors.ts +35 -0
  304. package/src/stateful/http-provider-event-emitter.ts +314 -0
  305. package/src/stateful/http-session-owner-registry.ts +306 -0
  306. package/src/stateful/index.ts +18 -0
  307. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  308. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  309. package/src/stateful/provider-event-pipeline.ts +61 -0
  310. package/src/stateful/provider-events.ts +462 -0
  311. package/src/stateful/session-key.ts +111 -0
  312. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  313. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  314. package/src/stateful/stateful-provider-adapter.ts +562 -0
  315. package/src/stateful/stateful-provider-observability.ts +261 -0
  316. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  317. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  318. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  319. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  320. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  321. package/src/stateful-signing.ts +46 -0
  322. package/src/stealth/profiles.ts +27 -33
  323. package/src/stream-evidence.ts +988 -0
  324. package/src/stream.ts +16 -20
  325. package/src/testing/index.ts +11 -2
  326. package/src/testing/run.ts +668 -74
  327. package/src/types.ts +665 -35
  328. package/src/user-input.ts +118 -0
  329. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  330. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  331. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  332. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  333. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  334. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  335. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  336. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  337. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  338. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  339. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  340. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  341. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  342. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -1,17 +1,30 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
 
3
- import { createProviderCache } from "../runtime/cache";
4
- import { createTestProviderChoiceContext } from "../runtime/choice";
5
- import { createMemoryProviderRuntimeState } from "../runtime/state";
6
- import { createUnsupportedSttClient } from "../runtime/stt";
7
- import { safeParseSchemaSync } from "../schema";
3
+ import { createProviderCache } from "../runtime/cache.js";
4
+ import { createTestProviderChoiceContext } from "../runtime/choice.js";
5
+ import { createMemoryProviderRuntimeState } from "../runtime/state.js";
6
+ import { createUnsupportedOcrClient } from "../runtime/ocr.js";
7
+ import { createUnsupportedSttClient } from "../runtime/stt.js";
8
+ import {
9
+ createNativeEgressAuthorization,
10
+ NativeNetworkError,
11
+ snapshotNativeConnectInput,
12
+ snapshotNativeGrantInput,
13
+ } from "../runtime/native-network.js";
14
+ import { safeParseSchemaSync } from "../schema.js";
15
+ import { requestPathForFixture } from "../fixture-sanitization.js";
16
+ import { findStreamCaptureGroup, replayStreamEvidence } from "../stream-evidence.js";
8
17
  import type {
9
18
  AuthMode,
19
+ BrowserPage,
10
20
  CredentialContext,
11
21
  HttpResponse,
22
+ NativeNetworkConnection,
12
23
  ProviderContext,
13
24
  ProviderDefinition,
14
- } from "../types";
25
+ StealthCookieStoreV1,
26
+ StealthResponse,
27
+ } from "../types.js";
15
28
 
16
29
  // Mirrors CONNECTOR_ID_REGEX in ../define.ts, which defineProvider() enforces.
17
30
  // A single lowercase segment (no hyphen) is a valid id, so the trailing group
@@ -22,8 +35,13 @@ const VALID_AUTH_MODES = [
22
35
  "platform-managed",
23
36
  "credentials",
24
37
  "oauth2",
38
+ "oauth2_proxied",
25
39
  ] as const;
26
40
  const UPDATE_SNAPSHOT_ARGS = new Set(["-u", "--update-snapshots"]);
41
+ const snapshotCaptureStates = new WeakMap<
42
+ ProviderContext,
43
+ { assertConsumed(): void; consumed(): number }
44
+ >();
27
45
 
28
46
  export interface StandardTestsManifest {
29
47
  id?: string;
@@ -49,6 +67,40 @@ export interface StandardTestsOptions {
49
67
  validateAuthMode?: boolean;
50
68
  /** Override inferred __fixtures__ directory for tests generated outside providers/<id>. */
51
69
  fixtureDir?: string;
70
+ /** Opt in to real-handler E2E with strict, offline canned upstream responses. */
71
+ upstreamStub?: StandardTestsUpstreamStub;
72
+ /** Require a committed snapshot; missing files fail unless --update-snapshots is passed. */
73
+ requireSnapshot?: boolean;
74
+ }
75
+
76
+ export interface StandardTestsUpstreamCall {
77
+ /** Operation whose real handler initiated the call. */
78
+ operationName: string;
79
+ /** ProviderContext transport surface used by the handler. */
80
+ transport: "http" | "stealth" | "browser" | "native";
81
+ method: string;
82
+ url?: string;
83
+ body?: unknown;
84
+ options?: unknown;
85
+ }
86
+
87
+ export interface StandardTestsUpstreamResponse {
88
+ status?: number;
89
+ headers?: Readonly<Record<string, string>>;
90
+ /** JSON-compatible values are encoded as JSON; strings and bytes are preserved. */
91
+ body?: unknown;
92
+ }
93
+
94
+ export type StandardTestsUpstreamStub = (
95
+ call: StandardTestsUpstreamCall,
96
+ ) =>
97
+ | Response
98
+ | StandardTestsUpstreamResponse
99
+ | undefined
100
+ | Promise<Response | StandardTestsUpstreamResponse | undefined>;
101
+
102
+ export interface StandardTestsResult {
103
+ warnings: readonly string[];
52
104
  }
53
105
 
54
106
  interface FixtureEnvelope {
@@ -118,9 +170,7 @@ function inferFixtureDir(providerId: string): string {
118
170
  .find((file) => file !== undefined);
119
171
 
120
172
  if (testFile) {
121
- const pathname = testFile.startsWith("file://")
122
- ? new URL(testFile).pathname
123
- : testFile;
173
+ const pathname = testFile.startsWith("file://") ? new URL(testFile).pathname : testFile;
124
174
  return `${pathname.replace(/\/[^/]+$/, "")}/../__fixtures__`;
125
175
  }
126
176
 
@@ -138,19 +188,426 @@ function jsonResponse(data: unknown): HttpResponse {
138
188
  json: async <_T = unknown>() => JSON.parse(JSON.stringify(data)),
139
189
  text: async () => body,
140
190
  arrayBuffer: async () =>
141
- bodyBytes.buffer.slice(
142
- bodyBytes.byteOffset,
143
- bodyBytes.byteOffset + bodyBytes.byteLength,
144
- ),
191
+ bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength),
145
192
  bytes: async () => bodyBytes.slice(0),
146
193
  };
147
194
  }
148
195
 
196
+ interface NormalizedUpstreamResponse {
197
+ status: number;
198
+ headers: Record<string, string>;
199
+ data: unknown;
200
+ text: string;
201
+ bytes: Uint8Array;
202
+ }
203
+
204
+ function headersToRecord(headers: Headers): Record<string, string> {
205
+ return Object.fromEntries(headers.entries());
206
+ }
207
+
208
+ async function normalizeUpstreamResponse(
209
+ response: Response | StandardTestsUpstreamResponse,
210
+ ): Promise<NormalizedUpstreamResponse> {
211
+ if (response instanceof Response) {
212
+ const bytes = new Uint8Array(await response.arrayBuffer());
213
+ const text = new TextDecoder().decode(bytes);
214
+ const headers = headersToRecord(response.headers);
215
+ let data: unknown = text;
216
+ if (response.headers.get("content-type")?.includes("application/json")) {
217
+ data = text.length > 0 ? JSON.parse(text) : null;
218
+ }
219
+ return { status: response.status, headers, data, text, bytes };
220
+ }
221
+
222
+ const headers = { ...(response.headers ?? {}) };
223
+ const body = response.body ?? null;
224
+ let bytes: Uint8Array;
225
+ let text: string;
226
+ let data: unknown;
227
+ if (body instanceof Uint8Array) {
228
+ bytes = body.slice(0);
229
+ text = new TextDecoder().decode(bytes);
230
+ data = text;
231
+ } else if (body instanceof ArrayBuffer) {
232
+ bytes = new Uint8Array(body.slice(0));
233
+ text = new TextDecoder().decode(bytes);
234
+ data = text;
235
+ } else if (typeof body === "string") {
236
+ text = body;
237
+ bytes = new TextEncoder().encode(text);
238
+ data = body;
239
+ } else {
240
+ text = JSON.stringify(body);
241
+ bytes = new TextEncoder().encode(text);
242
+ data = body;
243
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) {
244
+ headers["content-type"] = "application/json";
245
+ }
246
+ }
247
+ return { status: response.status ?? 200, headers, data, text, bytes };
248
+ }
249
+
250
+ function toHttpResponse(response: NormalizedUpstreamResponse): HttpResponse {
251
+ return {
252
+ status: response.status,
253
+ ok: response.status >= 200 && response.status < 300,
254
+ headers: response.headers,
255
+ data: response.data,
256
+ json: async <T = unknown>() => JSON.parse(response.text) as T,
257
+ text: async () => response.text,
258
+ arrayBuffer: async () => response.bytes.slice(0).buffer,
259
+ bytes: async () => response.bytes.slice(0),
260
+ };
261
+ }
262
+
263
+ const emptyCookieJar = {
264
+ get: () => undefined,
265
+ getAll: () => ({}),
266
+ toString: () => "",
267
+ };
268
+
269
+ function emptyCookieStore(): StealthCookieStoreV1 {
270
+ return {
271
+ version: 1,
272
+ jar: { cookies: [] } as unknown as StealthCookieStoreV1["jar"],
273
+ };
274
+ }
275
+
276
+ function toStealthResponse(response: NormalizedUpstreamResponse, url?: string): StealthResponse {
277
+ return {
278
+ status: response.status,
279
+ ok: response.status >= 200 && response.status < 300,
280
+ url,
281
+ redirected: false,
282
+ headers: response.headers,
283
+ rawHeaders: Object.entries(response.headers),
284
+ body: response.text,
285
+ cookies: emptyCookieJar,
286
+ json: async <T>() => JSON.parse(response.text) as T,
287
+ arrayBuffer: async () => response.bytes.slice(0).buffer,
288
+ bytes: async () => response.bytes.slice(0),
289
+ };
290
+ }
291
+
292
+ function streamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
293
+ return new ReadableStream({
294
+ start(controller) {
295
+ controller.enqueue(bytes.slice(0));
296
+ controller.close();
297
+ },
298
+ });
299
+ }
300
+
301
+ async function* singleBytes(bytes: Uint8Array): AsyncIterable<Uint8Array> {
302
+ yield bytes.slice(0);
303
+ }
304
+
305
+ async function* singleText(textValue: string): AsyncIterable<string> {
306
+ yield textValue;
307
+ }
308
+
309
+ function createUpstreamContext(
310
+ provider: ProviderDefinition,
311
+ operationName: string,
312
+ upstreamStub: StandardTestsUpstreamStub,
313
+ ): ProviderContext {
314
+ const credential: CredentialContext = {
315
+ mode: "none",
316
+ get: () => undefined,
317
+ getAll: () => ({}),
318
+ getAccessToken: () => undefined,
319
+ getScopes: () => [],
320
+ };
321
+ const request = { headers: {} };
322
+ const state = createMemoryProviderRuntimeState();
323
+ const nativeEgress = provider.native
324
+ ? createNativeEgressAuthorization({ egress: provider.native.network })
325
+ : undefined;
326
+ const requireNativeEgress = () => {
327
+ if (!nativeEgress)
328
+ throw new NativeNetworkError(
329
+ "Native egress authorization is unavailable",
330
+ "native_egress_policy_invalid",
331
+ );
332
+ return nativeEgress;
333
+ };
334
+ const dispatch = async (
335
+ call: Omit<StandardTestsUpstreamCall, "operationName">,
336
+ ): Promise<NormalizedUpstreamResponse> => {
337
+ const canned = await upstreamStub({ operationName, ...call });
338
+ if (canned === undefined) {
339
+ throw new Error(
340
+ `Unmatched upstream call for operation "${operationName}": ${call.transport}.${call.method}${call.url ? ` ${call.url}` : ""}. Add a canned response to upstreamStub; live network passthrough is disabled.`,
341
+ );
342
+ }
343
+ return normalizeUpstreamResponse(canned);
344
+ };
345
+ const httpCall = async (
346
+ method: string,
347
+ url: string,
348
+ body?: unknown,
349
+ options?: unknown,
350
+ ): Promise<HttpResponse> =>
351
+ toHttpResponse(await dispatch({ transport: "http", method, url, body, options }));
352
+ const stealthCall = async (url: string, options?: { method?: string; body?: unknown }) =>
353
+ toStealthResponse(
354
+ await dispatch({
355
+ transport: "stealth",
356
+ method: options?.method?.toUpperCase() ?? "GET",
357
+ url,
358
+ body: options?.body,
359
+ options,
360
+ }),
361
+ url,
362
+ );
363
+
364
+ const createBrowserPage = (): BrowserPage => {
365
+ let currentUrl = "about:blank";
366
+ let currentResponse: NormalizedUpstreamResponse | undefined;
367
+ const browserAction = async (method: string, body?: unknown) => {
368
+ currentResponse = await dispatch({
369
+ transport: "browser",
370
+ method,
371
+ url: currentUrl,
372
+ body,
373
+ });
374
+ return currentResponse;
375
+ };
376
+ const page: BrowserPage = {
377
+ id: `standard-test-${operationName}`,
378
+ url: async () => currentUrl,
379
+ title: async () => currentResponse?.text.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] ?? "",
380
+ content: async () => currentResponse?.text ?? "",
381
+ evaluate: async <T>(fn: string | (() => T)) =>
382
+ (await browserAction("evaluate", typeof fn === "string" ? fn : String(fn))).data as T,
383
+ locator: (selector) => ({
384
+ click: async () => {
385
+ await browserAction("locator.click", { selector });
386
+ },
387
+ fill: async (textValue) => {
388
+ await browserAction("locator.fill", { selector, text: textValue });
389
+ },
390
+ textContent: async () => {
391
+ const value = (await browserAction("locator.textContent", { selector })).data;
392
+ return value === null || value === undefined ? null : String(value);
393
+ },
394
+ waitFor: async (options) => {
395
+ await browserAction("locator.waitFor", { selector, options });
396
+ },
397
+ }),
398
+ close: async () => {},
399
+ cookies: async () =>
400
+ (await browserAction("cookies")).data as Awaited<ReturnType<BrowserPage["cookies"]>>,
401
+ fill: async (selector, textValue) => {
402
+ await browserAction("fill", { selector, text: textValue });
403
+ },
404
+ goto: async (url) => {
405
+ currentUrl = url;
406
+ await browserAction("goto");
407
+ },
408
+ screenshot: async (options) =>
409
+ Buffer.from((await browserAction("screenshot", options)).bytes),
410
+ click: async (selector) => {
411
+ await browserAction("click", { selector });
412
+ },
413
+ type: async (selector, textValue) => {
414
+ await browserAction("type", { selector, text: textValue });
415
+ },
416
+ waitForSelector: async (selector, options) => {
417
+ await browserAction("waitForSelector", { selector, options });
418
+ },
419
+ frames: async () => [page],
420
+ withResourcePolicy: async (_policy, run) => run(),
421
+ };
422
+ return page;
423
+ };
424
+
425
+ const createStealthSession = (): ReturnType<ProviderContext["stealth"]["createSession"]> => ({
426
+ fetch: stealthCall,
427
+ cookies: {
428
+ ...emptyCookieJar,
429
+ has: () => false,
430
+ setFromCookieStrings: () => {},
431
+ toHeader: () => "",
432
+ snapshot: () => ({}),
433
+ restore: () => {},
434
+ serialize: emptyCookieStore,
435
+ deserialize: () => {},
436
+ clear: () => {},
437
+ },
438
+ redirects: {
439
+ run: async (options) => {
440
+ const final = await stealthCall(options.url, options);
441
+ return {
442
+ final,
443
+ hops: [],
444
+ reason: "completed",
445
+ cookies: {},
446
+ cookieStore: emptyCookieStore(),
447
+ };
448
+ },
449
+ },
450
+ close: () => {},
451
+ });
452
+
453
+ return {
454
+ env: { get: () => undefined },
455
+ credential,
456
+ request,
457
+ http: {
458
+ request: (url, options) =>
459
+ httpCall(options?.method?.toUpperCase() ?? "GET", url, options?.body, options),
460
+ get: (url, options) => httpCall("GET", url, undefined, options),
461
+ post: (url, body, options) => httpCall("POST", url, body, options),
462
+ put: (url, body, options) => httpCall("PUT", url, body, options),
463
+ delete: (url, options) => httpCall("DELETE", url, undefined, options),
464
+ stream: async (url, options) => {
465
+ const response = await dispatch({
466
+ transport: "http",
467
+ method: options?.method?.toUpperCase() ?? "GET",
468
+ url,
469
+ body: options?.body,
470
+ options,
471
+ });
472
+ return {
473
+ status: response.status,
474
+ ok: response.status >= 200 && response.status < 300,
475
+ headers: response.headers,
476
+ body: streamFromBytes(response.bytes),
477
+ bytes: () => singleBytes(response.bytes),
478
+ textChunks: () => singleText(response.text),
479
+ lines: () => singleText(response.text),
480
+ };
481
+ },
482
+ sse: async (url, options) => {
483
+ const response = await dispatch({
484
+ transport: "http",
485
+ method: options?.method?.toUpperCase() ?? "GET",
486
+ url,
487
+ body: options?.body,
488
+ options,
489
+ });
490
+ async function* messages() {
491
+ for (const block of response.text.split(/\r?\n\r?\n/)) {
492
+ const data = block
493
+ .split(/\r?\n/)
494
+ .filter((line) => line.startsWith("data:"))
495
+ .map((line) => line.slice(5).trimStart())
496
+ .join("\n");
497
+ if (!data) continue;
498
+ yield { event: "message", data, json: <T>() => JSON.parse(data) as T };
499
+ }
500
+ }
501
+ return messages();
502
+ },
503
+ },
504
+ cache: createProviderCache({ providerId: `standard-test-${operationName}` }),
505
+ state,
506
+ stealth: {
507
+ fetch: stealthCall,
508
+ createSession: createStealthSession,
509
+ },
510
+ browser: {
511
+ engine: "playwright-stealth",
512
+ newPage: async () => createBrowserPage(),
513
+ rawPage: async () => createBrowserPage(),
514
+ withIsolatedContext: async (handler) => handler(createBrowserPage()),
515
+ solveChallenge: async (challenge) =>
516
+ (
517
+ await dispatch({
518
+ transport: "browser",
519
+ method: "solveChallenge",
520
+ body: challenge,
521
+ })
522
+ ).data as Awaited<ReturnType<ProviderContext["browser"]["solveChallenge"]>>,
523
+ },
524
+ ...(provider.native
525
+ ? {
526
+ native: {
527
+ network: {
528
+ connectTcp: async (options) => {
529
+ const request = snapshotNativeConnectInput(options);
530
+ requireNativeEgress().assertConnect(request, "disabled");
531
+ return createNativeConnection(
532
+ await dispatch({
533
+ transport: "native",
534
+ method: "connectTcp",
535
+ url: `tcp://${request.host}:${request.port}`,
536
+ options: request,
537
+ }),
538
+ dispatch,
539
+ `tcp://${request.host}:${request.port}`,
540
+ );
541
+ },
542
+ connectTls: async (options) => {
543
+ const request = snapshotNativeConnectInput(options);
544
+ requireNativeEgress().assertConnect(request, "required");
545
+ return createNativeConnection(
546
+ await dispatch({
547
+ transport: "native",
548
+ method: "connectTls",
549
+ url: `tls://${request.host}:${request.port}`,
550
+ options: request,
551
+ }),
552
+ dispatch,
553
+ `tls://${request.host}:${request.port}`,
554
+ );
555
+ },
556
+ grantTcpEgress: (input) => {
557
+ return requireNativeEgress().grant(snapshotNativeGrantInput(input));
558
+ },
559
+ },
560
+ },
561
+ }
562
+ : {}),
563
+ trace: { span: async (_name, fn) => fn() },
564
+ auth: { requestField: async (name) => unsupported(`ctx.auth.requestField(${name})`) },
565
+ ocr: createUnsupportedOcrClient(
566
+ "Standard test upstream context does not support ctx.ocr.recognize",
567
+ ),
568
+ stt: createUnsupportedSttClient(
569
+ "Standard test upstream context does not support ctx.stt.transcribe",
570
+ ),
571
+ resolver: {
572
+ solve: async () => unsupported("ctx.resolver.solve"),
573
+ },
574
+ choice: createTestProviderChoiceContext({
575
+ providerId: `standard-test-${operationName}`,
576
+ request,
577
+ credential,
578
+ state,
579
+ }),
580
+ };
581
+ }
582
+
583
+ function createNativeConnection(
584
+ initialResponse: NormalizedUpstreamResponse,
585
+ dispatch: (
586
+ call: Omit<StandardTestsUpstreamCall, "operationName">,
587
+ ) => Promise<NormalizedUpstreamResponse>,
588
+ url: string,
589
+ ): NativeNetworkConnection {
590
+ let unread = initialResponse.bytes.slice(0);
591
+ return {
592
+ read: async () => {
593
+ if (unread.byteLength === 0) return null;
594
+ const bytes = unread;
595
+ unread = new Uint8Array();
596
+ return bytes;
597
+ },
598
+ write: async (body) => {
599
+ const response = await dispatch({ transport: "native", method: "write", url, body });
600
+ unread = response.bytes.slice(0);
601
+ },
602
+ close: async () => {},
603
+ };
604
+ }
605
+
149
606
  function unsupported(name: string): never {
150
607
  throw new Error(`Standard test snapshot context does not support ${name}`);
151
608
  }
152
609
 
153
- function createSnapshotContext(rawFixture: unknown): ProviderContext {
610
+ export function createSnapshotContext(rawFixture: unknown): ProviderContext {
154
611
  const credential: CredentialContext = {
155
612
  mode: "none",
156
613
  get: () => undefined,
@@ -160,18 +617,61 @@ function createSnapshotContext(rawFixture: unknown): ProviderContext {
160
617
  };
161
618
  const request = { headers: {} };
162
619
  const state = createMemoryProviderRuntimeState();
620
+ const streamCaptureGroup = findStreamCaptureGroup(rawFixture);
621
+ let nextCaptureItem = 0;
622
+ const replayJsonResponse = () => {
623
+ if (!streamCaptureGroup) return jsonResponse(rawFixture);
624
+ const item = streamCaptureGroup.items[nextCaptureItem];
625
+ if (!item) {
626
+ throw new Error(
627
+ `Stream fixture exhausted: no recorded response exists for ordinary HTTP call ${nextCaptureItem + 1}.`,
628
+ );
629
+ }
630
+ if (item.kind !== "response") {
631
+ throw new Error(
632
+ `Stream fixture call-order mismatch: expected a stream call at position ${nextCaptureItem + 1}, received an ordinary HTTP call.`,
633
+ );
634
+ }
635
+ nextCaptureItem += 1;
636
+ return jsonResponse(item.value);
637
+ };
163
638
 
164
- return {
639
+ const context: ProviderContext = {
165
640
  env: { get: () => undefined },
166
641
  credential,
167
642
  request,
168
643
  http: {
169
- request: async () => jsonResponse(rawFixture),
170
- get: async () => jsonResponse(rawFixture),
171
- post: async () => jsonResponse(rawFixture),
172
- put: async () => jsonResponse(rawFixture),
173
- delete: async () => jsonResponse(rawFixture),
174
- stream: async () => unsupported("ctx.http.stream"),
644
+ request: async () => replayJsonResponse(),
645
+ get: async () => replayJsonResponse(),
646
+ post: async () => replayJsonResponse(),
647
+ put: async () => replayJsonResponse(),
648
+ delete: async () => replayJsonResponse(),
649
+ stream: async (...args) => {
650
+ if (!streamCaptureGroup) return unsupported("ctx.http.stream");
651
+ const item = streamCaptureGroup.items[nextCaptureItem];
652
+ if (!item) {
653
+ throw new Error(
654
+ `Stream fixture exhausted: no recorded evidence exists for stream call ${nextCaptureItem + 1}.`,
655
+ );
656
+ }
657
+ if (item.kind !== "stream") {
658
+ throw new Error(
659
+ `Stream fixture call-order mismatch: expected an ordinary HTTP call at position ${nextCaptureItem + 1}, received a stream call.`,
660
+ );
661
+ }
662
+ if (item.evidence.request) {
663
+ const expected = item.evidence.request;
664
+ const actualMethod = (args[1]?.method ?? "GET").toUpperCase();
665
+ const actualPath = replayRequestPath(args[0], expected.path);
666
+ if (actualMethod !== expected.method || actualPath !== expected.path) {
667
+ throw new Error(
668
+ `Stream fixture request mismatch: expected ${expected.method} ${expected.path}, received ${actualMethod} ${actualPath}.`,
669
+ );
670
+ }
671
+ }
672
+ nextCaptureItem += 1;
673
+ return replayStreamEvidence(item.evidence);
674
+ },
175
675
  sse: async () => unsupported("ctx.http.sse"),
176
676
  },
177
677
  cache: createProviderCache({ providerId: "standard-test" }),
@@ -184,20 +684,24 @@ function createSnapshotContext(rawFixture: unknown): ProviderContext {
184
684
  engine: "playwright-stealth",
185
685
  newPage: async () => unsupported("ctx.browser.newPage"),
186
686
  rawPage: async () => unsupported("ctx.browser.rawPage"),
187
- withIsolatedContext: async () =>
188
- unsupported("ctx.browser.withIsolatedContext"),
687
+ withIsolatedContext: async () => unsupported("ctx.browser.withIsolatedContext"),
189
688
  solveChallenge: async () => unsupported("ctx.browser.solveChallenge"),
190
689
  },
191
690
  trace: {
192
691
  span: async (_name, fn) => fn(),
193
692
  },
194
693
  auth: {
195
- requestField: async (name) =>
196
- unsupported(`ctx.auth.requestField(${name})`),
694
+ requestField: async (name) => unsupported(`ctx.auth.requestField(${name})`),
197
695
  },
696
+ ocr: createUnsupportedOcrClient(
697
+ "Standard test snapshot context does not support ctx.ocr.recognize",
698
+ ),
198
699
  stt: createUnsupportedSttClient(
199
700
  "Standard test snapshot context does not support ctx.stt.transcribe",
200
701
  ),
702
+ resolver: {
703
+ solve: async () => unsupported("ctx.resolver.solve"),
704
+ },
201
705
  choice: createTestProviderChoiceContext({
202
706
  providerId: "standard-test",
203
707
  request,
@@ -205,6 +709,26 @@ function createSnapshotContext(rawFixture: unknown): ProviderContext {
205
709
  state,
206
710
  }),
207
711
  };
712
+ snapshotCaptureStates.set(context, {
713
+ assertConsumed() {
714
+ if (streamCaptureGroup && nextCaptureItem !== streamCaptureGroup.items.length) {
715
+ throw new Error(
716
+ `Stream fixture has ${streamCaptureGroup.items.length - nextCaptureItem} unconsumed capture item${streamCaptureGroup.items.length - nextCaptureItem === 1 ? "" : "s"} after handler completion.`,
717
+ );
718
+ }
719
+ },
720
+ consumed: () => nextCaptureItem,
721
+ });
722
+ return context;
723
+ }
724
+
725
+ function replayRequestPath(requestUrl: string, expectedPath: string): string {
726
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(requestUrl) || requestUrl.startsWith("/")) {
727
+ return requestPathForFixture(requestUrl);
728
+ }
729
+ return requestPathForFixture(
730
+ new URL(requestUrl, `https://fixture.invalid${expectedPath}`).toString(),
731
+ );
208
732
  }
209
733
 
210
734
  async function transformSnapshotOutput(
@@ -212,14 +736,21 @@ async function transformSnapshotOutput(
212
736
  rawFixture: unknown,
213
737
  ): Promise<unknown> {
214
738
  const entries = Object.entries(provider.operations);
215
- const context = createSnapshotContext(rawFixture);
739
+ const captureStates: Array<{ assertConsumed(): void; consumed(): number }> = [];
216
740
  const outputs = await Promise.all(
217
741
  entries.map(async ([operationName, operation]) => {
742
+ const context = createSnapshotContext(rawFixture);
218
743
  const request = operation.fixtures?.request ?? {};
219
744
  const output = await operation.handler(context, request);
745
+ const captureState = snapshotCaptureStates.get(context);
746
+ if (captureState) captureStates.push(captureState);
747
+ if (captureState?.consumed()) captureState.assertConsumed();
220
748
  return [operationName, output] as const;
221
749
  }),
222
750
  );
751
+ if (findStreamCaptureGroup(rawFixture) && !captureStates.some((state) => state.consumed() > 0)) {
752
+ captureStates[0]?.assertConsumed();
753
+ }
223
754
 
224
755
  if (outputs.length === 1) {
225
756
  return outputs[0]?.[1];
@@ -244,7 +775,7 @@ function formatJsonDiff(current: unknown, expected: unknown): string {
244
775
 
245
776
  if (currentLine === expectedLine) {
246
777
  if (currentLine !== undefined) {
247
- lines.push(` ${currentLine}`);
778
+ lines.push(` ${currentLine}`);
248
779
  }
249
780
  continue;
250
781
  }
@@ -292,11 +823,59 @@ function parseSchemaFixture(
292
823
  operationName,
293
824
  fieldName,
294
825
  fixture,
295
- safeParseSchemaSync(
296
- schema,
297
- fixture,
298
- `operations.${operationName}.fixtures.${fieldName}`,
299
- ),
826
+ safeParseSchemaSync(schema, fixture, `operations.${operationName}.fixtures.${fieldName}`),
827
+ );
828
+ }
829
+
830
+ async function materializeHandlerOutput(output: unknown): Promise<unknown> {
831
+ if (!(output instanceof Response)) return output;
832
+ const contentType = output.headers.get("content-type") ?? "";
833
+ if (contentType.includes("application/json")) return output.json();
834
+ return output.text();
835
+ }
836
+
837
+ /** Internal execution seam exported for focused SDK tests; use runStandardTests as public API. */
838
+ export async function executeStandardTestHandler(
839
+ provider: ProviderDefinition,
840
+ operationName: string,
841
+ upstreamStub: StandardTestsUpstreamStub,
842
+ ): Promise<unknown> {
843
+ const operation = provider.operations[operationName];
844
+ if (!operation) throw new Error(`Unknown operation "${operationName}".`);
845
+ if (operation.fixtures?.request === undefined) {
846
+ throw new Error(
847
+ `Operation "${operationName}" has no fixtures.request for handler E2E execution.`,
848
+ );
849
+ }
850
+ const context = createUpstreamContext(provider, operationName, upstreamStub);
851
+ const output = await materializeHandlerOutput(
852
+ await operation.handler(context, operation.fixtures.request),
853
+ );
854
+ const result = safeParseSchemaSync(
855
+ operation.output,
856
+ output,
857
+ `operations.${operationName}.handler.output`,
858
+ );
859
+ if (!result.success) {
860
+ throw new Error(
861
+ [
862
+ `Handler output for operation "${operationName}" failed schema validation.`,
863
+ formatJsonDiff(
864
+ { valid: false, value: output, error: result.error },
865
+ { valid: true, value: output },
866
+ ),
867
+ ].join("\n"),
868
+ );
869
+ }
870
+ return output;
871
+ }
872
+
873
+ function isOptionsShortcut(value: unknown): value is StandardTestsOptions {
874
+ return (
875
+ value !== null &&
876
+ typeof value === "object" &&
877
+ !Array.isArray(value) &&
878
+ Object.hasOwn(value, "upstreamStub")
300
879
  );
301
880
  }
302
881
 
@@ -308,13 +887,39 @@ function parseSchemaFixture(
308
887
  * import { runStandardTests } from "@apifuse/provider-sdk/testing";
309
888
  * runStandardTests(myProvider, rawFixture, manifest, { snapshot: true });
310
889
  */
890
+ export function runStandardTests(
891
+ provider: ProviderDefinition,
892
+ options: StandardTestsOptions & { upstreamStub: StandardTestsUpstreamStub },
893
+ ): StandardTestsResult;
311
894
  export function runStandardTests(
312
895
  provider: ProviderDefinition,
313
896
  rawFixture?: unknown,
314
897
  manifest?: StandardTestsManifest,
315
- options: StandardTestsOptions = {},
316
- ): void {
898
+ options?: StandardTestsOptions,
899
+ ): StandardTestsResult;
900
+ export function runStandardTests(
901
+ provider: ProviderDefinition,
902
+ rawFixtureOrOptions?: unknown,
903
+ manifest?: StandardTestsManifest,
904
+ legacyOptions?: StandardTestsOptions,
905
+ ): StandardTestsResult {
906
+ const shortcut =
907
+ manifest === undefined && legacyOptions === undefined && isOptionsShortcut(rawFixtureOrOptions);
908
+ const rawFixture = shortcut ? undefined : rawFixtureOrOptions;
909
+ const options = shortcut ? rawFixtureOrOptions : (legacyOptions ?? {});
317
910
  const operations = Object.entries(provider.operations);
911
+ const warnings = options.upstreamStub
912
+ ? operations
913
+ .filter(([, operation]) => operation.fixtures?.request === undefined)
914
+ .map(
915
+ ([operationName]) =>
916
+ `[provider-sdk] Operation "${provider.id}.${operationName}" has no fixtures.request, so runStandardTests cannot invoke its handler E2E.`,
917
+ )
918
+ : operations.map(
919
+ ([operationName]) =>
920
+ `[provider-sdk] Operation "${provider.id}.${operationName}" has no handler E2E coverage in runStandardTests; configure upstreamStub to invoke the real handler.`,
921
+ );
922
+ for (const warning of warnings) console.warn(warning);
318
923
 
319
924
  const assertFixtureValidation = (): void => {
320
925
  expect(rawFixture).toBeDefined();
@@ -322,40 +927,20 @@ export function runStandardTests(
322
927
 
323
928
  for (const [operationName, op] of operations) {
324
929
  if (op.fixtures?.request !== undefined) {
325
- parseSchemaFixture(
326
- operationName,
327
- "request",
328
- op.input,
329
- op.fixtures.request,
330
- );
930
+ parseSchemaFixture(operationName, "request", op.input, op.fixtures.request);
331
931
  }
332
932
 
333
933
  if (op.fixtures?.response !== undefined) {
334
- parseSchemaFixture(
335
- operationName,
336
- "response",
337
- op.output,
338
- op.fixtures.response,
339
- );
934
+ parseSchemaFixture(operationName, "response", op.output, op.fixtures.response);
340
935
  }
341
936
 
342
937
  if (isFixtureEnvelope(rawFixture)) {
343
938
  if (rawFixture.request !== undefined) {
344
- parseSchemaFixture(
345
- operationName,
346
- "request",
347
- op.input,
348
- rawFixture.request,
349
- );
939
+ parseSchemaFixture(operationName, "request", op.input, rawFixture.request);
350
940
  }
351
941
 
352
942
  if (rawFixture.response !== undefined) {
353
- parseSchemaFixture(
354
- operationName,
355
- "response",
356
- op.output,
357
- rawFixture.response,
358
- );
943
+ parseSchemaFixture(operationName, "response", op.output, rawFixture.response);
359
944
  }
360
945
  }
361
946
  }
@@ -411,21 +996,11 @@ export function runStandardTests(
411
996
  it("operation schemas can parse fixture data", () => {
412
997
  for (const [operationName, op] of operations) {
413
998
  if (op.fixtures?.request !== undefined && op.input) {
414
- parseSchemaFixture(
415
- operationName,
416
- "request",
417
- op.input,
418
- op.fixtures.request,
419
- );
999
+ parseSchemaFixture(operationName, "request", op.input, op.fixtures.request);
420
1000
  }
421
1001
 
422
1002
  if (op.fixtures?.response !== undefined && op.output) {
423
- parseSchemaFixture(
424
- operationName,
425
- "response",
426
- op.output,
427
- op.fixtures.response,
428
- );
1003
+ parseSchemaFixture(operationName, "response", op.output, op.fixtures.response);
429
1004
  }
430
1005
 
431
1006
  expect(operationName).toBeTruthy();
@@ -467,15 +1042,34 @@ export function runStandardTests(
467
1042
  const serialized = stableStringify(actual);
468
1043
  const snapshotFile = Bun.file(snapshotPath);
469
1044
 
470
- if (shouldUpdateSnapshots() || !(await snapshotFile.exists())) {
1045
+ const snapshotExists = await snapshotFile.exists();
1046
+ if (!snapshotExists && options.requireSnapshot && !shouldUpdateSnapshots()) {
1047
+ throw new Error(
1048
+ `Required golden snapshot is missing: ${snapshotPath}. Regenerate it with bun test --update-snapshots.`,
1049
+ );
1050
+ }
1051
+ if (shouldUpdateSnapshots() || !snapshotExists) {
471
1052
  await Bun.write(snapshotPath, serialized);
472
1053
  }
473
1054
 
474
- const expected: unknown = JSON.parse(
475
- await Bun.file(snapshotPath).text(),
476
- );
1055
+ const expected: unknown = JSON.parse(await Bun.file(snapshotPath).text());
477
1056
  expect(actual).toEqual(expected);
478
1057
  });
479
1058
  }
1059
+
1060
+ if (options.upstreamStub) {
1061
+ for (const [operationName, operation] of operations) {
1062
+ if (operation.fixtures?.request === undefined) continue;
1063
+ it(`invokes the real ${operationName} handler with canned upstream responses`, async () => {
1064
+ await executeStandardTestHandler(
1065
+ provider,
1066
+ operationName,
1067
+ options.upstreamStub as StandardTestsUpstreamStub,
1068
+ );
1069
+ });
1070
+ }
1071
+ }
480
1072
  });
1073
+
1074
+ return { warnings };
481
1075
  }