@workos/oagen-emitters 0.0.1

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 (73) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.github/workflows/lint-pr-title.yml +16 -0
  3. package/.github/workflows/lint.yml +21 -0
  4. package/.github/workflows/release-please.yml +28 -0
  5. package/.github/workflows/release.yml +32 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.husky/pre-push +1 -0
  9. package/.node-version +1 -0
  10. package/.oxfmtrc.json +10 -0
  11. package/.oxlintrc.json +29 -0
  12. package/.vscode/settings.json +11 -0
  13. package/LICENSE.txt +21 -0
  14. package/README.md +123 -0
  15. package/commitlint.config.ts +1 -0
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +2158 -0
  18. package/docs/endpoint-coverage.md +275 -0
  19. package/docs/sdk-architecture/node.md +355 -0
  20. package/oagen.config.ts +51 -0
  21. package/package.json +83 -0
  22. package/renovate.json +26 -0
  23. package/smoke/sdk-dotnet.ts +903 -0
  24. package/smoke/sdk-elixir.ts +771 -0
  25. package/smoke/sdk-go.ts +948 -0
  26. package/smoke/sdk-kotlin.ts +799 -0
  27. package/smoke/sdk-node.ts +516 -0
  28. package/smoke/sdk-php.ts +699 -0
  29. package/smoke/sdk-python.ts +738 -0
  30. package/smoke/sdk-ruby.ts +723 -0
  31. package/smoke/sdk-rust.ts +774 -0
  32. package/src/compat/extractors/dotnet.ts +8 -0
  33. package/src/compat/extractors/elixir.ts +8 -0
  34. package/src/compat/extractors/go.ts +8 -0
  35. package/src/compat/extractors/kotlin.ts +8 -0
  36. package/src/compat/extractors/node.ts +8 -0
  37. package/src/compat/extractors/php.ts +8 -0
  38. package/src/compat/extractors/python.ts +8 -0
  39. package/src/compat/extractors/ruby.ts +8 -0
  40. package/src/compat/extractors/rust.ts +8 -0
  41. package/src/index.ts +1 -0
  42. package/src/node/client.ts +356 -0
  43. package/src/node/common.ts +203 -0
  44. package/src/node/config.ts +70 -0
  45. package/src/node/enums.ts +87 -0
  46. package/src/node/errors.ts +205 -0
  47. package/src/node/fixtures.ts +139 -0
  48. package/src/node/index.ts +57 -0
  49. package/src/node/manifest.ts +23 -0
  50. package/src/node/models.ts +323 -0
  51. package/src/node/naming.ts +96 -0
  52. package/src/node/resources.ts +380 -0
  53. package/src/node/serializers.ts +286 -0
  54. package/src/node/tests.ts +336 -0
  55. package/src/node/type-map.ts +56 -0
  56. package/src/node/utils.ts +164 -0
  57. package/test/compat/extractors/node.test.ts +145 -0
  58. package/test/fixtures/sample-sdk-node/package.json +7 -0
  59. package/test/fixtures/sample-sdk-node/src/client.ts +24 -0
  60. package/test/fixtures/sample-sdk-node/src/index.ts +4 -0
  61. package/test/fixtures/sample-sdk-node/src/models.ts +28 -0
  62. package/test/fixtures/sample-sdk-node/tsconfig.json +13 -0
  63. package/test/node/client.test.ts +165 -0
  64. package/test/node/enums.test.ts +128 -0
  65. package/test/node/errors.test.ts +65 -0
  66. package/test/node/models.test.ts +301 -0
  67. package/test/node/naming.test.ts +212 -0
  68. package/test/node/resources.test.ts +260 -0
  69. package/test/node/serializers.test.ts +206 -0
  70. package/test/node/type-map.test.ts +127 -0
  71. package/tsconfig.json +20 -0
  72. package/tsup.config.ts +8 -0
  73. package/vitest.config.ts +4 -0
@@ -0,0 +1,903 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * .NET SDK smoke test — captures wire-level HTTP exchanges from the generated
4
+ * .NET SDK and outputs SmokeResults JSON for diff comparison.
5
+ *
6
+ * Usage:
7
+ * npx tsx smoke/sdk-dotnet.ts --spec ../openapi-spec/spec/open-api-spec.yaml --sdk-path ./sdk
8
+ *
9
+ * Requires API_KEY or WORKOS_API_KEY env var.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
13
+ import { resolve, join } from 'node:path';
14
+ import { execSync, spawn } from 'node:child_process';
15
+ import { createServer, IncomingMessage, ServerResponse } from 'node:http';
16
+ import { request as httpsRequest } from 'node:https';
17
+ import {
18
+ parseSpec,
19
+ planOperations,
20
+ planWaves,
21
+ generateCamelPayload,
22
+ generateCamelQueryParams,
23
+ IdRegistry,
24
+ delay,
25
+ parseCliArgs,
26
+ loadSmokeConfig,
27
+ getExpectedStatusCodes,
28
+ isUnexpectedStatus,
29
+ toCamelCase,
30
+ SERVICE_PROPERTY_MAP,
31
+ } from '@workos/oagen/smoke';
32
+ import type { CapturedExchange, SmokeResults, ExchangeProvenance, OperationWave } from '@workos/oagen/smoke';
33
+ import type { Operation } from '@workos/oagen';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface ManifestEntry {
40
+ sdkMethod: string;
41
+ service: string;
42
+ }
43
+
44
+ interface CapturedRequest {
45
+ method: string;
46
+ path: string;
47
+ queryParams: Record<string, string>;
48
+ body: unknown | null;
49
+ }
50
+
51
+ interface CapturedResponse {
52
+ status: number;
53
+ body: unknown | null;
54
+ }
55
+
56
+ interface MethodResolution {
57
+ service: string;
58
+ method: string;
59
+ tier: ExchangeProvenance['resolutionTier'];
60
+ confidence: number;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Proxy server
65
+ // ---------------------------------------------------------------------------
66
+
67
+ interface ProxyCapture {
68
+ request: CapturedRequest;
69
+ response: CapturedResponse;
70
+ }
71
+
72
+ function createProxyServer(apiKey: string, captures: ProxyCapture[]): Promise<{ port: number; close: () => void }> {
73
+ return new Promise((resolve) => {
74
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
75
+ const chunks: Buffer[] = [];
76
+ req.on('data', (c: Buffer) => chunks.push(c));
77
+ req.on('end', () => {
78
+ let body: unknown = null;
79
+ if (chunks.length > 0) {
80
+ try {
81
+ body = JSON.parse(Buffer.concat(chunks).toString());
82
+ } catch {
83
+ body = Buffer.concat(chunks).toString();
84
+ }
85
+ }
86
+ const url = new URL(req.url!, `http://localhost`);
87
+ const queryParams: Record<string, string> = {};
88
+ url.searchParams.forEach((v, k) => {
89
+ queryParams[k] = v;
90
+ });
91
+
92
+ const options = {
93
+ hostname: 'api.workos.com',
94
+ port: 443,
95
+ path: req.url,
96
+ method: req.method,
97
+ headers: {
98
+ ...req.headers,
99
+ host: 'api.workos.com',
100
+ authorization: `Bearer ${apiKey}`,
101
+ },
102
+ };
103
+
104
+ const proxyReq = httpsRequest(options, (proxyRes) => {
105
+ const resChunks: Buffer[] = [];
106
+ proxyRes.on('data', (c: Buffer) => resChunks.push(c));
107
+ proxyRes.on('end', () => {
108
+ let resBody: unknown = null;
109
+ if (resChunks.length > 0) {
110
+ try {
111
+ resBody = JSON.parse(Buffer.concat(resChunks).toString());
112
+ } catch {
113
+ resBody = Buffer.concat(resChunks).toString();
114
+ }
115
+ }
116
+
117
+ captures.push({
118
+ request: { method: req.method!, path: url.pathname, queryParams, body },
119
+ response: { status: proxyRes.statusCode!, body: resBody },
120
+ });
121
+
122
+ res.writeHead(proxyRes.statusCode!, proxyRes.headers);
123
+ res.end(Buffer.concat(resChunks));
124
+ });
125
+ });
126
+
127
+ proxyReq.on('error', (err) => {
128
+ console.error('Proxy request error:', err.message);
129
+ res.writeHead(502);
130
+ res.end('Proxy error');
131
+ });
132
+
133
+ if (chunks.length > 0) proxyReq.write(Buffer.concat(chunks));
134
+ proxyReq.end();
135
+ });
136
+ });
137
+
138
+ server.listen(0, () => {
139
+ const addr = server.address() as any;
140
+ resolve({ port: addr.port, close: () => server.close() });
141
+ });
142
+ });
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Manifest loading
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
150
+ const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
151
+ if (!existsSync(manifestPath)) {
152
+ console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
153
+ console.warn(' Method resolution will rely on heuristic tiers — most operations may be skipped.');
154
+ return null;
155
+ }
156
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
157
+ const manifest = new Map<string, ManifestEntry>();
158
+ for (const [httpKey, entry] of Object.entries(raw)) {
159
+ manifest.set(httpKey, entry as ManifestEntry);
160
+ }
161
+ return manifest;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Method resolution — 2 tiers: manifest, exact match
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Convert a camelCase or snake_case name to PascalCase for .NET conventions.
170
+ */
171
+ function toPascalCase(name: string): string {
172
+ const camel = toCamelCase(name);
173
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
174
+ }
175
+
176
+ function resolveMethod(
177
+ op: Operation,
178
+ irService: string,
179
+ manifest: Map<string, ManifestEntry> | null,
180
+ ): MethodResolution | null {
181
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
182
+
183
+ // Tier 0: Manifest match (primary for generated SDKs)
184
+ if (manifest) {
185
+ const entry = manifest.get(httpKey);
186
+ if (entry) {
187
+ return {
188
+ service: entry.service,
189
+ method: entry.sdkMethod,
190
+ tier: 'manifest',
191
+ confidence: 1.0,
192
+ };
193
+ }
194
+ }
195
+
196
+ // Tier 1: Exact match — IR operation name in PascalCase + Async suffix
197
+ const sdkProp = SERVICE_PROPERTY_MAP[irService] || toPascalCase(irService);
198
+ const exactName = toPascalCase(op.name) + 'Async';
199
+ return {
200
+ service: sdkProp,
201
+ method: exactName,
202
+ tier: 'exact',
203
+ confidence: 0.8,
204
+ };
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Argument construction (for .NET driver code generation)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function buildDotnetArgs(
212
+ op: Operation,
213
+ pathParams: Record<string, string>,
214
+ spec: any,
215
+ ): {
216
+ positionalArgs: string[];
217
+ bodyPayload: Record<string, unknown> | null;
218
+ queryOpts: Record<string, unknown> | null;
219
+ } {
220
+ const positionalArgs: string[] = [];
221
+ let bodyPayload: Record<string, unknown> | null = null;
222
+ let queryOpts: Record<string, unknown> | null = null;
223
+
224
+ // Path params as positional args
225
+ for (const p of op.pathParams) {
226
+ positionalArgs.push(pathParams[p.name]);
227
+ }
228
+
229
+ // Request body (camelCase — will be converted to PascalCase in C# generation)
230
+ if (op.requestBody) {
231
+ bodyPayload = generateCamelPayload(op, spec);
232
+ }
233
+
234
+ // Query params (camelCase — will be converted to PascalCase in C# generation)
235
+ if (!op.requestBody && op.queryParams.some((p) => p.required)) {
236
+ const params = generateCamelQueryParams(op, spec);
237
+ if (Object.keys(params).length > 0) {
238
+ queryOpts = params;
239
+ }
240
+ }
241
+
242
+ // Pagination
243
+ if (op.pagination) {
244
+ if (!queryOpts) queryOpts = {};
245
+ queryOpts['limit'] = 1;
246
+ }
247
+
248
+ return { positionalArgs, bodyPayload, queryOpts };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // C# value serialization for code generation
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function toCSharpValue(value: unknown, indent: number = 12): string {
256
+ if (value === null || value === undefined) return 'null';
257
+ if (typeof value === 'string') return `"${value}"`;
258
+ if (typeof value === 'number') return Number.isInteger(value) ? `${value}` : `${value}m`;
259
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
260
+ if (Array.isArray(value)) {
261
+ const pad = ' '.repeat(indent);
262
+ const items = value.map((v) => `${pad} ${toCSharpValue(v, indent + 4)}`);
263
+ return `new List<object>\n${pad}{\n${items.join(',\n')}\n${pad}}`;
264
+ }
265
+ if (typeof value === 'object') {
266
+ const pad = ' '.repeat(indent);
267
+ const entries = Object.entries(value as Record<string, unknown>)
268
+ .map(([k, v]) => `${pad} { "${k}", ${toCSharpValue(v, indent + 4)} }`)
269
+ .join(',\n');
270
+ return `new Dictionary<string, object>\n${pad}{\n${entries}\n${pad}}`;
271
+ }
272
+ return `${value}`;
273
+ }
274
+
275
+ function toCSharpObjectInitializer(obj: Record<string, unknown>, indent: number = 12): string {
276
+ return toCSharpValue(obj, indent);
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Batched C# source generation
281
+ // ---------------------------------------------------------------------------
282
+
283
+ interface PlannedCall {
284
+ index: number;
285
+ op: Operation;
286
+ irService: string;
287
+ resolution: MethodResolution;
288
+ pathParams: Record<string, string>;
289
+ }
290
+
291
+ /**
292
+ * Build a single Program.cs that calls ALL planned operations sequentially.
293
+ * Each call is wrapped with stderr markers for correlation with proxy captures.
294
+ */
295
+ function buildBatchedCSharpScript(port: number, ns: string, calls: PlannedCall[], spec: any): string {
296
+ const lines: string[] = [];
297
+
298
+ // Preamble — loaded once
299
+ lines.push('using System;');
300
+ lines.push('using System.Net.Http;');
301
+ lines.push('using System.Net.Http.Headers;');
302
+ lines.push('using System.Text;');
303
+ lines.push('using System.Threading.Tasks;');
304
+ lines.push('using Newtonsoft.Json;');
305
+ lines.push(`using ${ns};`);
306
+ lines.push('');
307
+
308
+ // Shared HttpClient for body operations
309
+ lines.push('var httpClient = new HttpClient();');
310
+ lines.push(`httpClient.BaseAddress = new Uri("http://localhost:${port}");`);
311
+ lines.push('httpClient.Timeout = TimeSpan.FromSeconds(30);');
312
+ lines.push('httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "api_key");');
313
+ lines.push('httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));');
314
+ lines.push('httpClient.DefaultRequestHeaders.ExpectContinue = false;');
315
+ lines.push('');
316
+
317
+ // Shared SDK client for GET/DELETE operations
318
+ lines.push(`var client = new ${ns}Client(apiKey: "api_key", baseUrl: "http://localhost:${port}");`);
319
+ lines.push('');
320
+
321
+ for (const call of calls) {
322
+ const { index, op, resolution, pathParams } = call;
323
+ const { positionalArgs, bodyPayload, queryOpts } = buildDotnetArgs(op, pathParams, spec);
324
+
325
+ // Marker: start
326
+ lines.push(`Console.Error.WriteLine("OAGEN_CALL_START:${index}");`);
327
+ lines.push('Console.Error.Flush();');
328
+
329
+ lines.push('try');
330
+ lines.push('{');
331
+
332
+ if (bodyPayload) {
333
+ // For body operations (POST/PUT/PATCH), use HttpClient directly
334
+ const payloadJson = JSON.stringify(bodyPayload).replace(/\\/g, '\\\\').replace(/"/g, '""');
335
+
336
+ let urlPath = op.path;
337
+ for (let i = 0; i < op.pathParams.length; i++) {
338
+ urlPath = urlPath.replace(`{${op.pathParams[i].name}}`, positionalArgs[i] ?? 'test_id');
339
+ }
340
+
341
+ const httpMethod = op.httpMethod.toUpperCase();
342
+
343
+ lines.push(` var payloadJson_${index} = @"${payloadJson}";`);
344
+ lines.push(
345
+ ` var content_${index} = new StringContent(payloadJson_${index}, Encoding.UTF8, "application/json");`,
346
+ );
347
+
348
+ if (httpMethod === 'POST') {
349
+ lines.push(` var response_${index} = await httpClient.PostAsync("${urlPath}", content_${index});`);
350
+ } else if (httpMethod === 'PUT') {
351
+ lines.push(` var response_${index} = await httpClient.PutAsync("${urlPath}", content_${index});`);
352
+ } else if (httpMethod === 'PATCH') {
353
+ lines.push(
354
+ ` var request_${index} = new HttpRequestMessage(new HttpMethod("PATCH"), "${urlPath}") { Content = content_${index} };`,
355
+ );
356
+ lines.push(` var response_${index} = await httpClient.SendAsync(request_${index});`);
357
+ } else {
358
+ lines.push(` var response_${index} = await httpClient.PostAsync("${urlPath}", content_${index});`);
359
+ }
360
+
361
+ lines.push(` var responseBody_${index} = await response_${index}.Content.ReadAsStringAsync();`);
362
+ lines.push(` Console.WriteLine(responseBody_${index});`);
363
+ } else {
364
+ // For GET/DELETE/list operations, call the SDK directly
365
+ const argsStr = positionalArgs.map((a) => `"${a}"`).join(', ');
366
+ let callParts: string[] = [];
367
+ if (argsStr) {
368
+ callParts.push(argsStr);
369
+ }
370
+ if (queryOpts) {
371
+ callParts.push(toCSharpObjectInitializer(queryOpts));
372
+ }
373
+ const callArgsStr = callParts.join(', ');
374
+
375
+ lines.push(` var result_${index} = await client.${resolution.service}.${resolution.method}(${callArgsStr});`);
376
+ lines.push(` Console.WriteLine(JsonConvert.SerializeObject(result_${index}, Formatting.Indented));`);
377
+ }
378
+
379
+ lines.push(` Console.Error.WriteLine("OAGEN_CALL_OK:${index}");`);
380
+ lines.push('}');
381
+ lines.push('catch (Exception ex)');
382
+ lines.push('{');
383
+ lines.push(` Console.Error.WriteLine($"OAGEN_CALL_ERROR:${index}:{{ex.GetType().Name}}: {{ex.Message}}");`);
384
+ lines.push('}');
385
+
386
+ // Marker: end
387
+ lines.push(`Console.Error.WriteLine("OAGEN_CALL_END:${index}");`);
388
+ lines.push('Console.Error.Flush();');
389
+
390
+ // Small delay to let proxy settle
391
+ lines.push('await Task.Delay(50);');
392
+ lines.push('');
393
+ }
394
+
395
+ return lines.join('\n') + '\n';
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // .NET project discovery
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Find the .csproj file in the SDK directory. Returns the full resolved path.
404
+ */
405
+ function findCsproj(sdkPath: string): string {
406
+ const files = readdirSync(sdkPath).filter((f) => f.endsWith('.csproj'));
407
+ if (files.length === 0) {
408
+ throw new Error(`No .csproj file found in ${sdkPath}`);
409
+ }
410
+ return resolve(sdkPath, files[0]);
411
+ }
412
+
413
+ /**
414
+ * Detect the root namespace from the .csproj file's RootNamespace property.
415
+ * Falls back to the csproj filename (without extension) if not found.
416
+ */
417
+ function detectNamespace(sdkPath: string): string {
418
+ const csprojPath = findCsproj(sdkPath);
419
+ const content = readFileSync(csprojPath, 'utf-8');
420
+ const match = content.match(/<RootNamespace>([^<]+)<\/RootNamespace>/);
421
+ if (match) return match[1];
422
+ // Fallback: use .csproj filename without extension
423
+ const base = csprojPath.split('/').pop() ?? '';
424
+ return base.replace('.csproj', '');
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // .NET project generation
429
+ // ---------------------------------------------------------------------------
430
+
431
+ function writeDotnetProject(tmpDir: string, _sdkPath: string, programCs: string): void {
432
+ writeFileSync(join(tmpDir, 'Program.cs'), programCs);
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Spawn-based wave execution
437
+ // ---------------------------------------------------------------------------
438
+
439
+ /**
440
+ * Run `dotnet run` for a wave via `spawn` so stderr markers can be parsed
441
+ * in real-time and the proxy event loop stays responsive.
442
+ */
443
+ function runDotnetWave(
444
+ tmpDir: string,
445
+ apiKey: string,
446
+ proxyPort: number,
447
+ captures: ProxyCapture[],
448
+ ): Promise<
449
+ Map<
450
+ number,
451
+ {
452
+ captureIndexBefore: number;
453
+ captureIndexAfter: number;
454
+ error?: string;
455
+ startTime: number;
456
+ endTime: number;
457
+ }
458
+ >
459
+ > {
460
+ return new Promise((resolvePromise, rejectPromise) => {
461
+ const callResults = new Map<
462
+ number,
463
+ {
464
+ captureIndexBefore: number;
465
+ captureIndexAfter: number;
466
+ error?: string;
467
+ startTime: number;
468
+ endTime: number;
469
+ }
470
+ >();
471
+
472
+ let currentCallIndex = -1;
473
+ let currentCallStart = Date.now();
474
+ let currentCapturesBefore = 0;
475
+
476
+ const child = spawn('dotnet', ['run', '--no-restore'], {
477
+ cwd: tmpDir,
478
+ env: {
479
+ ...process.env,
480
+ WORKOS_API_KEY: apiKey,
481
+ WORKOS_BASE_URL: `http://localhost:${proxyPort}`,
482
+ DOTNET_NOLOGO: '1',
483
+ },
484
+ stdio: ['pipe', 'pipe', 'pipe'],
485
+ });
486
+
487
+ const timeout = setTimeout(() => {
488
+ child.kill('SIGKILL');
489
+ rejectPromise(new Error('Batch dotnet script timed out after 300s'));
490
+ }, 300_000);
491
+
492
+ let stderrBuf = '';
493
+
494
+ child.stderr.on('data', (data: Buffer) => {
495
+ stderrBuf += data.toString();
496
+ const lines = stderrBuf.split('\n');
497
+ stderrBuf = lines.pop() || '';
498
+
499
+ for (const line of lines) {
500
+ const trimmed = line.trim();
501
+ if (trimmed.startsWith('OAGEN_CALL_START:')) {
502
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_START:'.length), 10);
503
+ currentCallIndex = idx;
504
+ currentCallStart = Date.now();
505
+ currentCapturesBefore = captures.length;
506
+ } else if (trimmed.startsWith('OAGEN_CALL_OK:')) {
507
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_OK:'.length), 10);
508
+ if (callResults.has(idx)) continue;
509
+ callResults.set(idx, {
510
+ captureIndexBefore: currentCapturesBefore,
511
+ captureIndexAfter: captures.length,
512
+ startTime: currentCallStart,
513
+ endTime: Date.now(),
514
+ });
515
+ } else if (trimmed.startsWith('OAGEN_CALL_ERROR:')) {
516
+ const rest = trimmed.slice('OAGEN_CALL_ERROR:'.length);
517
+ const colonIdx = rest.indexOf(':');
518
+ const idx = parseInt(rest.slice(0, colonIdx), 10);
519
+ const errMsg = rest.slice(colonIdx + 1);
520
+ if (callResults.has(idx)) continue;
521
+ callResults.set(idx, {
522
+ captureIndexBefore: currentCapturesBefore,
523
+ captureIndexAfter: captures.length,
524
+ error: errMsg,
525
+ startTime: currentCallStart,
526
+ endTime: Date.now(),
527
+ });
528
+ } else if (trimmed.startsWith('OAGEN_CALL_END:')) {
529
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_END:'.length), 10);
530
+ const existing = callResults.get(idx);
531
+ if (existing) {
532
+ existing.captureIndexAfter = captures.length;
533
+ existing.endTime = Date.now();
534
+ }
535
+ }
536
+ }
537
+ });
538
+
539
+ child.on('close', () => {
540
+ clearTimeout(timeout);
541
+ // Process any remaining stderr buffer
542
+ if (stderrBuf.trim()) {
543
+ const trimmed = stderrBuf.trim();
544
+ if (trimmed.startsWith('OAGEN_CALL_END:') && currentCallIndex >= 0) {
545
+ const existing = callResults.get(currentCallIndex);
546
+ if (existing) {
547
+ existing.captureIndexAfter = captures.length;
548
+ existing.endTime = Date.now();
549
+ }
550
+ }
551
+ }
552
+ resolvePromise(callResults);
553
+ });
554
+
555
+ child.on('error', (err) => {
556
+ clearTimeout(timeout);
557
+ rejectPromise(err);
558
+ });
559
+ });
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Main
564
+ // ---------------------------------------------------------------------------
565
+
566
+ async function main(): Promise<void> {
567
+ const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
568
+
569
+ if (!sdkPath) {
570
+ console.error('--sdk-path is required');
571
+ process.exit(1);
572
+ }
573
+
574
+ const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
575
+ if (!apiKey) {
576
+ console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
577
+ process.exit(1);
578
+ }
579
+
580
+ // Load config
581
+ loadSmokeConfig(smokeConfig);
582
+
583
+ // Parse spec
584
+ console.log('Parsing spec...');
585
+ const spec = await parseSpec(specPath);
586
+ console.log(`Spec: ${spec.name} v${spec.version}`);
587
+
588
+ // Detect SDK namespace
589
+ const ns = detectNamespace(sdkPath);
590
+ console.log(`SDK namespace: ${ns}`);
591
+
592
+ // Load manifest
593
+ const manifest = loadManifest(sdkPath);
594
+
595
+ // Start proxy
596
+ const captures: ProxyCapture[] = [];
597
+ const proxy = await createProxyServer(apiKey, captures);
598
+ console.log(`Proxy listening on port ${proxy.port}`);
599
+
600
+ // Plan operations
601
+ const groups = planOperations(spec);
602
+ const ids = new IdRegistry();
603
+ const exchanges: CapturedExchange[] = [];
604
+
605
+ let successCount = 0;
606
+ let errorCount = 0;
607
+ let skipCount = 0;
608
+ let unexpectedCount = 0;
609
+
610
+ // Create temp directory for .NET driver (clean any stale state)
611
+ const tmpDir = resolve(sdkPath, '.smoke-tmp-dotnet');
612
+ if (existsSync(tmpDir)) {
613
+ rmSync(tmpDir, { recursive: true, force: true });
614
+ }
615
+
616
+ // Step 1: Build the SDK project to a DLL
617
+ const sdkCsprojPath = findCsproj(sdkPath);
618
+ console.log('Building SDK...');
619
+ try {
620
+ execSync(`dotnet build "${sdkCsprojPath}" -c Release -o "${resolve(sdkPath, 'bin/Release/net8.0')}"`, {
621
+ cwd: sdkPath,
622
+ timeout: 120000,
623
+ stdio: ['pipe', 'pipe', 'pipe'],
624
+ env: { ...process.env, DOTNET_NOLOGO: '1' },
625
+ });
626
+ console.log('SDK built successfully');
627
+ } catch (err) {
628
+ const msg = err instanceof Error ? err.message : String(err);
629
+ console.error(`Failed to build SDK: ${msg}`);
630
+ process.exit(1);
631
+ }
632
+
633
+ // Find the SDK DLL
634
+ const sdkDllDir = resolve(sdkPath, 'bin/Release/net8.0');
635
+ const sdkDll = resolve(sdkDllDir, `${ns}.dll`);
636
+
637
+ // Step 2: Bootstrap the driver project referencing the built DLL
638
+ mkdirSync(tmpDir, { recursive: true });
639
+ const csprojPath = join(tmpDir, 'SmokeDriver.csproj');
640
+ const csprojContent = `<Project Sdk="Microsoft.NET.Sdk">
641
+ <PropertyGroup>
642
+ <OutputType>Exe</OutputType>
643
+ <TargetFramework>net8.0</TargetFramework>
644
+ <ImplicitUsings>enable</ImplicitUsings>
645
+ <Nullable>enable</Nullable>
646
+ </PropertyGroup>
647
+ <ItemGroup>
648
+ <Reference Include="${ns}">
649
+ <HintPath>${sdkDll}</HintPath>
650
+ </Reference>
651
+ <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
652
+ </ItemGroup>
653
+ </Project>
654
+ `;
655
+ writeFileSync(csprojPath, csprojContent);
656
+ writeFileSync(join(tmpDir, 'Program.cs'), 'Console.WriteLine("bootstrap");');
657
+
658
+ // Build the driver once to warm up
659
+ try {
660
+ execSync('dotnet build', {
661
+ cwd: tmpDir,
662
+ timeout: 120000,
663
+ stdio: ['pipe', 'pipe', 'pipe'],
664
+ env: { ...process.env, DOTNET_NOLOGO: '1' },
665
+ });
666
+ console.log('Driver project bootstrapped');
667
+ } catch (err) {
668
+ const msg = err instanceof Error ? err.message : String(err);
669
+ console.error(`Failed to build driver: ${msg}`);
670
+ process.exit(1);
671
+ }
672
+
673
+ // Use wave-based planning: execute parameterless ops first, extract IDs,
674
+ // then plan the next wave of ops whose path params are now resolvable.
675
+ let globalCallIndex = 0;
676
+
677
+ const waveIterator = planWaves(groups, ids, (op, irService) => {
678
+ const resolution = resolveMethod(op, irService, manifest);
679
+ return resolution !== null;
680
+ });
681
+
682
+ let waveNumber = 0;
683
+ let waveResult = waveIterator.next();
684
+
685
+ try {
686
+ while (!waveResult.done) {
687
+ const wave: OperationWave = waveResult.value;
688
+ waveNumber++;
689
+
690
+ // Build planned calls for this wave, resolving methods
691
+ const plannedCalls: PlannedCall[] = [];
692
+ const waveSkipped: Array<{ op: Operation; irService: string; reason: string }> = [];
693
+
694
+ for (const { op, irService, pathParams } of wave.calls) {
695
+ const resolution = resolveMethod(op, irService, manifest);
696
+ if (!resolution) {
697
+ waveSkipped.push({ op, irService, reason: 'No matching SDK method' });
698
+ continue;
699
+ }
700
+ plannedCalls.push({
701
+ index: globalCallIndex++,
702
+ op,
703
+ irService,
704
+ resolution,
705
+ pathParams,
706
+ });
707
+ }
708
+
709
+ // Record skipped exchanges for this wave
710
+ for (const skip of waveSkipped) {
711
+ exchanges.push(makeSkippedExchange(skip.op, skip.irService, skip.reason));
712
+ skipCount++;
713
+ }
714
+
715
+ if (plannedCalls.length === 0) {
716
+ waveResult = waveIterator.next();
717
+ continue;
718
+ }
719
+
720
+ console.log(`\n=== Wave ${waveNumber} (${plannedCalls.length} operations) ===`);
721
+
722
+ // Generate batched C# script for this wave
723
+ const programCs = buildBatchedCSharpScript(proxy.port, ns, plannedCalls, spec);
724
+
725
+ writeDotnetProject(tmpDir, sdkPath, programCs);
726
+
727
+ // Execute the batched script via spawn
728
+ let callResults: Map<
729
+ number,
730
+ {
731
+ captureIndexBefore: number;
732
+ captureIndexAfter: number;
733
+ error?: string;
734
+ startTime: number;
735
+ endTime: number;
736
+ }
737
+ >;
738
+
739
+ try {
740
+ callResults = await runDotnetWave(tmpDir, apiKey, proxy.port, captures);
741
+ } catch (err) {
742
+ const message = err instanceof Error ? err.message : String(err);
743
+ console.error(`Batch execution error: ${message}`);
744
+ callResults = new Map();
745
+ }
746
+
747
+ await delay(200);
748
+
749
+ // Process results for this wave — extract IDs so the next wave can use them
750
+ for (const call of plannedCalls) {
751
+ const { index, op, irService, resolution } = call;
752
+ const isTopLevel = op.pathParams.length === 0;
753
+ const result = callResults.get(index);
754
+
755
+ if (!result) {
756
+ exchanges.push({
757
+ ...makeSkippedExchange(op, irService, 'Call did not execute (batch script may have failed)'),
758
+ outcome: 'api-error',
759
+ durationMs: 0,
760
+ });
761
+ errorCount++;
762
+ console.log(` X ${op.name} -- did not execute`);
763
+ continue;
764
+ }
765
+
766
+ const elapsed = result.endTime - result.startTime;
767
+
768
+ if (result.captureIndexAfter <= result.captureIndexBefore) {
769
+ if (result.error) {
770
+ exchanges.push({
771
+ ...makeSkippedExchange(op, irService, result.error),
772
+ outcome: 'api-error',
773
+ durationMs: elapsed,
774
+ });
775
+ errorCount++;
776
+ console.log(` X ${op.name} -- ${result.error.split('\n')[0]}`);
777
+ } else {
778
+ exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
779
+ skipCount++;
780
+ console.log(` SKIP ${op.name} -- no HTTP capture`);
781
+ }
782
+ continue;
783
+ }
784
+
785
+ const capture = captures[result.captureIndexAfter - 1];
786
+ const exchange = buildExchange(op, irService, capture, elapsed, resolution);
787
+
788
+ if (result.error) {
789
+ exchange.error = result.error;
790
+ }
791
+
792
+ // Extract IDs from response (critical: feeds the next wave)
793
+ ids.extractAndStore(irService, capture.response.body, isTopLevel);
794
+
795
+ if (exchange.unexpectedStatus) {
796
+ unexpectedCount++;
797
+ console.log(` ! ${op.name} -> ${capture.response.status} (unexpected)`);
798
+ } else if (exchange.outcome === 'api-error') {
799
+ errorCount++;
800
+ console.log(` X ${op.name} -> ${capture.response.status}`);
801
+ } else {
802
+ successCount++;
803
+ console.log(` OK ${op.name} -> ${capture.response.status} (${elapsed}ms)`);
804
+ }
805
+
806
+ exchanges.push(exchange);
807
+ }
808
+
809
+ // Advance to the next wave (IDs from this wave are now in the registry)
810
+ waveResult = waveIterator.next();
811
+ }
812
+ } finally {
813
+ // Cleanup temp directory
814
+ if (existsSync(tmpDir)) {
815
+ rmSync(tmpDir, { recursive: true, force: true });
816
+ }
817
+ proxy.close();
818
+ }
819
+
820
+ // Record any operations that could never be resolved
821
+ if (waveResult.done && waveResult.value) {
822
+ for (const unresolved of waveResult.value) {
823
+ exchanges.push(makeSkippedExchange(unresolved.operation, unresolved.service, 'Missing path param IDs'));
824
+ skipCount++;
825
+ }
826
+ }
827
+
828
+ // Write results
829
+ const results: SmokeResults = {
830
+ source: 'sdk-dotnet',
831
+ timestamp: new Date().toISOString(),
832
+ specVersion: spec.version,
833
+ exchanges,
834
+ };
835
+
836
+ const outputPath = 'smoke-results-sdk-dotnet.json';
837
+ writeFileSync(outputPath, JSON.stringify(results, null, 2));
838
+ console.log(`\nResults written to ${outputPath}`);
839
+
840
+ // Summary
841
+ const total = exchanges.length;
842
+ console.log(`\n=== Summary ===`);
843
+ console.log(` Total: ${total}`);
844
+ console.log(` Success: ${successCount}`);
845
+ console.log(` API errors: ${errorCount}`);
846
+ console.log(` Skipped: ${skipCount}`);
847
+ if (unexpectedCount > 0) {
848
+ console.log(` Unexpected: ${unexpectedCount}`);
849
+ }
850
+ }
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // Helpers
854
+ // ---------------------------------------------------------------------------
855
+
856
+ function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
857
+ return {
858
+ operationId: op.name,
859
+ service,
860
+ operationName: op.name,
861
+ request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
862
+ response: { status: 0, body: null },
863
+ outcome: 'skipped',
864
+ error: reason,
865
+ durationMs: 0,
866
+ };
867
+ }
868
+
869
+ function buildExchange(
870
+ op: Operation,
871
+ service: string,
872
+ capture: ProxyCapture,
873
+ durationMs: number,
874
+ resolution: MethodResolution,
875
+ ): CapturedExchange {
876
+ const status = capture.response.status;
877
+ const expectedCodes = getExpectedStatusCodes(op);
878
+ const unexpected = isUnexpectedStatus(status, op);
879
+
880
+ return {
881
+ operationId: op.name,
882
+ service,
883
+ operationName: op.name,
884
+ request: capture.request,
885
+ response: capture.response,
886
+ outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
887
+ unexpectedStatus: unexpected || undefined,
888
+ expectedStatusCodes: expectedCodes,
889
+ durationMs,
890
+ provenance: {
891
+ resolutionTier: resolution.tier,
892
+ resolutionConfidence: resolution.confidence,
893
+ sdkMethodName: `${resolution.service}.${resolution.method}`,
894
+ captureIndex: 0,
895
+ totalCaptures: 1,
896
+ },
897
+ };
898
+ }
899
+
900
+ main().catch((err) => {
901
+ console.error('Fatal error:', err);
902
+ process.exit(1);
903
+ });