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

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