@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.21

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