@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,799 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Kotlin SDK smoke test — captures wire-level HTTP exchanges from the generated
4
+ * Kotlin SDK and outputs SmokeResults JSON for diff comparison.
5
+ *
6
+ * Uses wave-based planning: executes parameterless ops first, extracts IDs,
7
+ * then plans the next wave of ops whose path params are now resolvable.
8
+ * Each wave generates ONE batched Main.kt with all SDK calls, built and run
9
+ * once via Gradle to eliminate per-operation cold start overhead.
10
+ *
11
+ * Usage:
12
+ * npx tsx smoke/sdk-kotlin.ts --spec ../openapi-spec/spec/open-api-spec.yaml --sdk-path ./sdk
13
+ *
14
+ * Requires API_KEY or WORKOS_API_KEY env var.
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
18
+ import { resolve, join } from 'node:path';
19
+ import { spawn } from 'node:child_process';
20
+ import { createServer, IncomingMessage, ServerResponse } from 'node:http';
21
+ import { request as httpsRequest } from 'node:https';
22
+ import {
23
+ parseSpec,
24
+ planOperations,
25
+ planWaves,
26
+ generateCamelPayload,
27
+ generateCamelQueryParams,
28
+ IdRegistry,
29
+ delay,
30
+ parseCliArgs,
31
+ loadSmokeConfig,
32
+ getExpectedStatusCodes,
33
+ isUnexpectedStatus,
34
+ toCamelCase,
35
+ SERVICE_PROPERTY_MAP,
36
+ } from '@workos/oagen/smoke';
37
+ import type { CapturedExchange, SmokeResults, ExchangeProvenance, OperationWave } from '@workos/oagen/smoke';
38
+ import type { Operation } from '@workos/oagen';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ interface ManifestEntry {
45
+ sdkMethod: string;
46
+ service: string;
47
+ }
48
+
49
+ interface CapturedRequest {
50
+ method: string;
51
+ path: string;
52
+ queryParams: Record<string, string>;
53
+ body: unknown | null;
54
+ }
55
+
56
+ interface CapturedResponse {
57
+ status: number;
58
+ body: unknown | null;
59
+ }
60
+
61
+ interface MethodResolution {
62
+ service: string;
63
+ method: string;
64
+ tier: ExchangeProvenance['resolutionTier'];
65
+ confidence: number;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Proxy server
70
+ // ---------------------------------------------------------------------------
71
+
72
+ interface ProxyCapture {
73
+ request: CapturedRequest;
74
+ response: CapturedResponse;
75
+ }
76
+
77
+ function createProxyServer(
78
+ apiKey: string,
79
+ captures: ProxyCapture[],
80
+ ): Promise<{ port: number; close: () => Promise<void> }> {
81
+ return new Promise((resolvePromise) => {
82
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
83
+ const chunks: Buffer[] = [];
84
+ req.on('data', (c: Buffer) => chunks.push(c));
85
+ req.on('end', () => {
86
+ let body: unknown = null;
87
+ if (chunks.length > 0) {
88
+ try {
89
+ body = JSON.parse(Buffer.concat(chunks).toString());
90
+ } catch {
91
+ body = Buffer.concat(chunks).toString();
92
+ }
93
+ }
94
+ const url = new URL(req.url!, `http://localhost`);
95
+ const queryParams: Record<string, string> = {};
96
+ url.searchParams.forEach((v, k) => {
97
+ queryParams[k] = v;
98
+ });
99
+
100
+ const options = {
101
+ hostname: 'api.workos.com',
102
+ port: 443,
103
+ path: req.url,
104
+ method: req.method,
105
+ headers: {
106
+ ...req.headers,
107
+ host: 'api.workos.com',
108
+ authorization: `Bearer ${apiKey}`,
109
+ },
110
+ };
111
+
112
+ const proxyReq = httpsRequest(options, (proxyRes) => {
113
+ const resChunks: Buffer[] = [];
114
+ proxyRes.on('data', (c: Buffer) => resChunks.push(c));
115
+ proxyRes.on('end', () => {
116
+ let resBody: unknown = null;
117
+ if (resChunks.length > 0) {
118
+ try {
119
+ resBody = JSON.parse(Buffer.concat(resChunks).toString());
120
+ } catch {
121
+ resBody = Buffer.concat(resChunks).toString();
122
+ }
123
+ }
124
+
125
+ captures.push({
126
+ request: { method: req.method!, path: url.pathname, queryParams, body },
127
+ response: { status: proxyRes.statusCode!, body: resBody },
128
+ });
129
+
130
+ res.writeHead(proxyRes.statusCode!, proxyRes.headers);
131
+ res.end(Buffer.concat(resChunks));
132
+ });
133
+ });
134
+
135
+ proxyReq.on('error', (err) => {
136
+ console.error('Proxy request error:', err.message);
137
+ captures.push({
138
+ request: { method: req.method!, path: url.pathname, queryParams, body },
139
+ response: { status: 502, body: { error: err.message } },
140
+ });
141
+ res.writeHead(502);
142
+ res.end('Proxy error');
143
+ });
144
+
145
+ if (chunks.length > 0) proxyReq.write(Buffer.concat(chunks));
146
+ proxyReq.end();
147
+ });
148
+ });
149
+
150
+ server.listen(0, () => {
151
+ const addr = server.address() as any;
152
+ resolvePromise({
153
+ port: addr.port,
154
+ close: () =>
155
+ new Promise<void>((r) => {
156
+ server.close(() => r());
157
+ }),
158
+ });
159
+ });
160
+ });
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Manifest loading
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
168
+ const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
169
+ if (!existsSync(manifestPath)) {
170
+ console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
171
+ console.warn(' Method resolution will rely on heuristic tiers — most operations may be skipped.');
172
+ return null;
173
+ }
174
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
175
+ const manifest = new Map<string, ManifestEntry>();
176
+ for (const [httpKey, entry] of Object.entries(raw)) {
177
+ manifest.set(httpKey, entry as ManifestEntry);
178
+ }
179
+ return manifest;
180
+ }
181
+
182
+ /**
183
+ * Detect the main client class name from the generated SDK source.
184
+ * Looks for a .kt file in the root package directory that contains `class XxxClient` or `class Xxx(`.
185
+ */
186
+ function detectClientClassName(sdkPath: string): string {
187
+ // Look for Kotlin files in src/main/kotlin/com/workos/ (root package dir)
188
+ const rootPkgDir = join(sdkPath, 'src', 'main', 'kotlin', 'com', 'workos');
189
+ if (!existsSync(rootPkgDir)) return 'WorkOS';
190
+ const files = readdirSync(rootPkgDir).filter((f) => f.endsWith('.kt'));
191
+ for (const file of files) {
192
+ const content = readFileSync(join(rootPkgDir, file), 'utf-8');
193
+ const match = content.match(/^class\s+(\w+)\s*\(/m);
194
+ if (match) return match[1];
195
+ }
196
+ return 'WorkOS';
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Method resolution — 2 tiers: manifest, exact match
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function resolveMethod(
204
+ op: Operation,
205
+ irService: string,
206
+ manifest: Map<string, ManifestEntry> | null,
207
+ ): MethodResolution | null {
208
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
209
+
210
+ // Tier 0: Manifest match (primary for generated SDKs)
211
+ if (manifest) {
212
+ const entry = manifest.get(httpKey);
213
+ if (entry) {
214
+ return {
215
+ service: entry.service,
216
+ method: entry.sdkMethod,
217
+ tier: 'manifest',
218
+ confidence: 1.0,
219
+ };
220
+ }
221
+ }
222
+
223
+ // Tier 1: Exact match — IR operation name in camelCase
224
+ const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
225
+ const exactName = toCamelCase(op.name);
226
+ return {
227
+ service: sdkProp,
228
+ method: exactName,
229
+ tier: 'exact',
230
+ confidence: 0.8,
231
+ };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Argument construction (for Kotlin driver code generation)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function buildKotlinArgs(
239
+ op: Operation,
240
+ pathParams: Record<string, string>,
241
+ spec: any,
242
+ ): {
243
+ positionalArgs: string[];
244
+ bodyPayload: Record<string, unknown> | null;
245
+ queryOpts: Record<string, unknown> | null;
246
+ } {
247
+ const positionalArgs: string[] = [];
248
+ let bodyPayload: Record<string, unknown> | null = null;
249
+ let queryOpts: Record<string, unknown> | null = null;
250
+
251
+ // Path params as positional args
252
+ for (const p of op.pathParams) {
253
+ positionalArgs.push(pathParams[p.name]);
254
+ }
255
+
256
+ // Request body (camelCase for Kotlin)
257
+ if (op.requestBody) {
258
+ bodyPayload = generateCamelPayload(op, spec);
259
+ }
260
+
261
+ // Query params (camelCase for Kotlin)
262
+ if (!op.requestBody && op.queryParams.some((p) => p.required)) {
263
+ const params = generateCamelQueryParams(op, spec);
264
+ if (Object.keys(params).length > 0) {
265
+ queryOpts = params;
266
+ }
267
+ }
268
+
269
+ // Pagination
270
+ if (op.pagination) {
271
+ if (!queryOpts) queryOpts = {};
272
+ queryOpts['limit'] = 1;
273
+ }
274
+
275
+ return { positionalArgs, bodyPayload, queryOpts };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Kotlin value serialization for code generation
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Serialize a value to Kotlin code that produces a plain Kotlin object (String, Int, Map, List, etc.)
284
+ * suitable for use in a HashMap<String, Any>.
285
+ */
286
+ function toKotlinAnyValue(value: unknown): string {
287
+ if (value === null || value === undefined) return '"" as Any';
288
+ if (typeof value === 'string') return `"${value}" as Any`;
289
+ if (typeof value === 'number') return `${value} as Any`;
290
+ if (typeof value === 'boolean') return `${value} as Any`;
291
+ if (Array.isArray(value)) {
292
+ const items = value.map((v) => toKotlinAnyValue(v));
293
+ return `listOf(${items.join(', ')}) as Any`;
294
+ }
295
+ if (typeof value === 'object') {
296
+ const entries = Object.entries(value as Record<string, unknown>)
297
+ .map(([k, v]) => `"${k}" to ${toKotlinAnyValue(v)}`)
298
+ .join(', ');
299
+ return `hashMapOf(${entries}) as Any`;
300
+ }
301
+ return `"${value}" as Any`;
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Batched Kotlin script generation
306
+ // ---------------------------------------------------------------------------
307
+
308
+ interface PlannedCall {
309
+ index: number;
310
+ op: Operation;
311
+ irService: string;
312
+ resolution: MethodResolution;
313
+ pathParams: Record<string, string>;
314
+ }
315
+
316
+ /**
317
+ * Build a single Main.kt that calls ALL planned operations sequentially.
318
+ * Each call is wrapped with stderr markers for correlation with proxy captures.
319
+ */
320
+ function buildBatchedKotlinScript(
321
+ port: number,
322
+ calls: PlannedCall[],
323
+ spec: any,
324
+ clientClassName: string = 'WorkOS',
325
+ ): string {
326
+ const lines: string[] = [];
327
+
328
+ // Preamble
329
+ lines.push(`import com.workos.${clientClassName}`);
330
+ lines.push('');
331
+ lines.push('fun main() {');
332
+ lines.push(` val client = ${clientClassName}(apiKey = "api_key", baseUrl = "http://localhost:${port}")`);
333
+ lines.push('');
334
+
335
+ for (const call of calls) {
336
+ const { index, op, resolution, pathParams } = call;
337
+ const { positionalArgs, bodyPayload } = buildKotlinArgs(op, pathParams, spec);
338
+
339
+ const argsStr = positionalArgs.map((a) => `"${a}"`).join(', ');
340
+
341
+ let callParts: string[] = [];
342
+ if (argsStr) {
343
+ callParts.push(argsStr);
344
+ }
345
+
346
+ if (bodyPayload) {
347
+ const bodyEntries = Object.entries(bodyPayload)
348
+ .map(([k, v]) => ` "${k}" to ${toKotlinAnyValue(v)}`)
349
+ .join(',\n');
350
+ const bodyBlock = `hashMapOf(\n${bodyEntries}\n ) as Any`;
351
+ callParts.push(bodyBlock);
352
+ }
353
+ // Skip query opts for Kotlin — paginated methods have typed ListOptions
354
+ // classes that cannot be constructed from a raw Map. SDK defaults apply.
355
+
356
+ const callArgsStr = callParts.join(', ');
357
+
358
+ // Marker: start
359
+ lines.push(` System.err.println("OAGEN_CALL_START:${index}")`);
360
+ lines.push(` System.err.flush()`);
361
+
362
+ lines.push(' try {');
363
+ lines.push(` val result${index} = client.${resolution.service}.${resolution.method}(${callArgsStr})`);
364
+ lines.push(` println(result${index})`);
365
+ lines.push(` System.err.println("OAGEN_CALL_OK:${index}")`);
366
+ lines.push(' } catch (e: Exception) {');
367
+ lines.push(` System.err.println("OAGEN_CALL_ERROR:${index}:\${e.javaClass.name}: \${e.message}")`);
368
+ lines.push(' }');
369
+
370
+ // Marker: end
371
+ lines.push(` System.err.println("OAGEN_CALL_END:${index}")`);
372
+ lines.push(` System.err.flush()`);
373
+
374
+ // Small sleep to let proxy settle
375
+ lines.push(' Thread.sleep(50)');
376
+ lines.push('');
377
+ }
378
+
379
+ lines.push('}');
380
+ return lines.join('\n');
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Gradle project generation
385
+ // ---------------------------------------------------------------------------
386
+
387
+ function writeGradleProject(tmpDir: string, sdkPath: string, mainKt: string): void {
388
+ const srcDir = join(tmpDir, 'src', 'main', 'kotlin');
389
+ mkdirSync(srcDir, { recursive: true });
390
+ writeFileSync(join(srcDir, 'Main.kt'), mainKt);
391
+
392
+ const sdkSrcDir = resolve(sdkPath, 'src', 'main', 'kotlin').replace(/\\/g, '/');
393
+ const buildGradle = `plugins {
394
+ kotlin("jvm") version "2.1.10"
395
+ application
396
+ }
397
+
398
+ repositories {
399
+ mavenCentral()
400
+ mavenLocal()
401
+ }
402
+
403
+ sourceSets {
404
+ main {
405
+ kotlin {
406
+ srcDir("${sdkSrcDir}")
407
+ }
408
+ }
409
+ }
410
+
411
+ dependencies {
412
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
413
+ implementation("com.fasterxml.jackson.core:jackson-databind:2.16.0")
414
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.16.0")
415
+ }
416
+
417
+ kotlin {
418
+ jvmToolchain(19)
419
+ }
420
+
421
+ application {
422
+ mainClass.set("MainKt")
423
+ }
424
+ `;
425
+ writeFileSync(join(tmpDir, 'build.gradle.kts'), buildGradle);
426
+
427
+ const settingsGradle = `rootProject.name = "smoke-driver"
428
+ `;
429
+ writeFileSync(join(tmpDir, 'settings.gradle.kts'), settingsGradle);
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Main
434
+ // ---------------------------------------------------------------------------
435
+
436
+ async function main(): Promise<void> {
437
+ const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
438
+
439
+ if (!sdkPath) {
440
+ console.error('--sdk-path is required');
441
+ process.exit(1);
442
+ }
443
+
444
+ const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
445
+ if (!apiKey) {
446
+ console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
447
+ process.exit(1);
448
+ }
449
+
450
+ // Load config
451
+ loadSmokeConfig(smokeConfig);
452
+
453
+ // Parse spec
454
+ console.log('Parsing spec...');
455
+ const spec = await parseSpec(specPath);
456
+ console.log(`Spec: ${spec.name} v${spec.version}`);
457
+
458
+ // Load manifest
459
+ const manifest = loadManifest(sdkPath);
460
+
461
+ // Detect client class name from generated SDK
462
+ const clientClass = detectClientClassName(sdkPath);
463
+ console.log(`Client class: ${clientClass}`);
464
+
465
+ // Start proxy (captures array-based)
466
+ const captures: ProxyCapture[] = [];
467
+ const proxy = await createProxyServer(apiKey, captures);
468
+ console.log(`Proxy listening on port ${proxy.port}`);
469
+
470
+ // Plan operations
471
+ const groups = planOperations(spec);
472
+ const ids = new IdRegistry();
473
+ const exchanges: CapturedExchange[] = [];
474
+
475
+ let successCount = 0;
476
+ let errorCount = 0;
477
+ let skipCount = 0;
478
+ let unexpectedCount = 0;
479
+
480
+ // Create temp directory for Kotlin driver
481
+ const tmpDir = resolve(sdkPath, '.smoke-tmp-kotlin');
482
+
483
+ // Use wave-based planning: execute parameterless ops first, extract IDs,
484
+ // then plan the next wave of ops whose path params are now resolvable.
485
+ let globalCallIndex = 0;
486
+
487
+ const waveIterator = planWaves(groups, ids, (op, irService) => {
488
+ const resolution = resolveMethod(op, irService, manifest);
489
+ return resolution !== null;
490
+ });
491
+
492
+ let waveNumber = 0;
493
+ let waveResult = waveIterator.next();
494
+
495
+ try {
496
+ while (!waveResult.done) {
497
+ const wave: OperationWave = waveResult.value;
498
+ waveNumber++;
499
+
500
+ // Build planned calls for this wave, resolving methods
501
+ const plannedCalls: PlannedCall[] = [];
502
+ const waveSkipped: Array<{ op: Operation; irService: string; reason: string }> = [];
503
+
504
+ for (const { op, irService, pathParams } of wave.calls) {
505
+ const resolution = resolveMethod(op, irService, manifest);
506
+ if (!resolution) {
507
+ waveSkipped.push({ op, irService, reason: 'No matching SDK method' });
508
+ continue;
509
+ }
510
+ plannedCalls.push({
511
+ index: globalCallIndex++,
512
+ op,
513
+ irService,
514
+ resolution,
515
+ pathParams,
516
+ });
517
+ }
518
+
519
+ // Record skipped exchanges for this wave
520
+ for (const skip of waveSkipped) {
521
+ exchanges.push(makeSkippedExchange(skip.op, skip.irService, skip.reason));
522
+ skipCount++;
523
+ }
524
+
525
+ if (plannedCalls.length === 0) {
526
+ waveResult = waveIterator.next();
527
+ continue;
528
+ }
529
+
530
+ console.log(`\n=== Wave ${waveNumber} (${plannedCalls.length} operations) ===`);
531
+
532
+ // Generate batched Kotlin script for this wave
533
+ const mainKt = buildBatchedKotlinScript(proxy.port, plannedCalls, spec, clientClass);
534
+
535
+ // Write Gradle project with the batched Main.kt
536
+ writeGradleProject(tmpDir, sdkPath, mainKt);
537
+
538
+ // Execute the batched script using spawn for real-time stderr parsing
539
+ const callResults = new Map<
540
+ number,
541
+ {
542
+ captureIndexBefore: number;
543
+ captureIndexAfter: number;
544
+ error?: string;
545
+ startTime: number;
546
+ endTime: number;
547
+ }
548
+ >();
549
+
550
+ let currentCallIndex = -1;
551
+ let currentCallStart = Date.now();
552
+ let currentCapturesBefore = 0;
553
+
554
+ try {
555
+ await new Promise<void>((resolvePromise, rejectPromise) => {
556
+ const child = spawn('gradle', ['run', '--quiet'], {
557
+ cwd: tmpDir,
558
+ env: {
559
+ ...process.env,
560
+ WORKOS_API_KEY: apiKey,
561
+ WORKOS_BASE_URL: `http://localhost:${proxy.port}`,
562
+ },
563
+ stdio: ['pipe', 'pipe', 'pipe'],
564
+ });
565
+
566
+ const timeout = setTimeout(() => {
567
+ child.kill('SIGKILL');
568
+ rejectPromise(new Error('Batch Kotlin script timed out after 300s'));
569
+ }, 300_000);
570
+
571
+ let stderrBuf = '';
572
+
573
+ child.stderr.on('data', (data: Buffer) => {
574
+ stderrBuf += data.toString();
575
+ const lines = stderrBuf.split('\n');
576
+ stderrBuf = lines.pop() || '';
577
+
578
+ for (const line of lines) {
579
+ const trimmed = line.trim();
580
+ if (trimmed.startsWith('OAGEN_CALL_START:')) {
581
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_START:'.length), 10);
582
+ currentCallIndex = idx;
583
+ currentCallStart = Date.now();
584
+ currentCapturesBefore = captures.length;
585
+ } else if (trimmed.startsWith('OAGEN_CALL_OK:')) {
586
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_OK:'.length), 10);
587
+ if (callResults.has(idx)) continue;
588
+ callResults.set(idx, {
589
+ captureIndexBefore: currentCapturesBefore,
590
+ captureIndexAfter: captures.length,
591
+ startTime: currentCallStart,
592
+ endTime: Date.now(),
593
+ });
594
+ } else if (trimmed.startsWith('OAGEN_CALL_ERROR:')) {
595
+ const rest = trimmed.slice('OAGEN_CALL_ERROR:'.length);
596
+ const colonIdx = rest.indexOf(':');
597
+ const idx = parseInt(rest.slice(0, colonIdx), 10);
598
+ const errMsg = rest.slice(colonIdx + 1);
599
+ if (callResults.has(idx)) continue;
600
+ callResults.set(idx, {
601
+ captureIndexBefore: currentCapturesBefore,
602
+ captureIndexAfter: captures.length,
603
+ error: errMsg,
604
+ startTime: currentCallStart,
605
+ endTime: Date.now(),
606
+ });
607
+ } else if (trimmed.startsWith('OAGEN_CALL_END:')) {
608
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_END:'.length), 10);
609
+ const existing = callResults.get(idx);
610
+ if (existing) {
611
+ existing.captureIndexAfter = captures.length;
612
+ existing.endTime = Date.now();
613
+ }
614
+ }
615
+ }
616
+ });
617
+
618
+ child.on('close', () => {
619
+ clearTimeout(timeout);
620
+ if (stderrBuf.trim()) {
621
+ const trimmed = stderrBuf.trim();
622
+ if (trimmed.startsWith('OAGEN_CALL_END:') && currentCallIndex >= 0) {
623
+ const existing = callResults.get(currentCallIndex);
624
+ if (existing) {
625
+ existing.captureIndexAfter = captures.length;
626
+ existing.endTime = Date.now();
627
+ }
628
+ }
629
+ }
630
+ resolvePromise();
631
+ });
632
+
633
+ child.on('error', (err) => {
634
+ clearTimeout(timeout);
635
+ rejectPromise(err);
636
+ });
637
+ });
638
+ } catch (err) {
639
+ const message = err instanceof Error ? err.message : String(err);
640
+ console.error(`Batch execution error: ${message}`);
641
+ }
642
+
643
+ await delay(200);
644
+
645
+ // Process results for this wave — extract IDs so the next wave can use them
646
+ for (const call of plannedCalls) {
647
+ const { index, op, irService, resolution } = call;
648
+ const isTopLevel = op.pathParams.length === 0;
649
+ const result = callResults.get(index);
650
+
651
+ if (!result) {
652
+ exchanges.push({
653
+ ...makeSkippedExchange(op, irService, 'Call did not execute (batch script may have failed)'),
654
+ outcome: 'api-error',
655
+ durationMs: 0,
656
+ });
657
+ errorCount++;
658
+ console.log(` X ${op.name} -- did not execute`);
659
+ continue;
660
+ }
661
+
662
+ const elapsed = result.endTime - result.startTime;
663
+
664
+ if (result.captureIndexAfter <= result.captureIndexBefore) {
665
+ if (result.error) {
666
+ exchanges.push({
667
+ ...makeSkippedExchange(op, irService, result.error),
668
+ outcome: 'api-error',
669
+ durationMs: elapsed,
670
+ });
671
+ errorCount++;
672
+ console.log(` X ${op.name} -- ${result.error.split('\n')[0]}`);
673
+ } else {
674
+ exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
675
+ skipCount++;
676
+ console.log(` SKIP ${op.name} -- no HTTP capture`);
677
+ }
678
+ continue;
679
+ }
680
+
681
+ const capture = captures[result.captureIndexAfter - 1];
682
+ const exchange = buildExchange(op, irService, capture, elapsed, resolution);
683
+
684
+ if (result.error) {
685
+ exchange.error = result.error;
686
+ }
687
+
688
+ // Extract IDs from response (critical: feeds the next wave)
689
+ ids.extractAndStore(irService, capture.response.body, isTopLevel);
690
+
691
+ if (exchange.unexpectedStatus) {
692
+ unexpectedCount++;
693
+ console.log(` ! ${op.name} -> ${capture.response.status} (unexpected)`);
694
+ } else if (exchange.outcome === 'api-error') {
695
+ errorCount++;
696
+ console.log(` X ${op.name} -> ${capture.response.status}`);
697
+ } else {
698
+ successCount++;
699
+ console.log(` OK ${op.name} -> ${capture.response.status} (${elapsed}ms)`);
700
+ }
701
+
702
+ exchanges.push(exchange);
703
+ }
704
+
705
+ // Advance to the next wave (IDs from this wave are now in the registry)
706
+ waveResult = waveIterator.next();
707
+ }
708
+ } finally {
709
+ // Cleanup temp directory
710
+ if (existsSync(tmpDir)) {
711
+ rmSync(tmpDir, { recursive: true, force: true });
712
+ }
713
+ await proxy.close();
714
+ }
715
+
716
+ // Record any operations that could never be resolved
717
+ if (waveResult.done && waveResult.value) {
718
+ for (const unresolved of waveResult.value) {
719
+ exchanges.push(makeSkippedExchange(unresolved.operation, unresolved.service, 'Missing path param IDs'));
720
+ skipCount++;
721
+ }
722
+ }
723
+
724
+ // Write results
725
+ const results: SmokeResults = {
726
+ source: 'sdk-kotlin',
727
+ timestamp: new Date().toISOString(),
728
+ specVersion: spec.version,
729
+ exchanges,
730
+ };
731
+
732
+ const outputPath = 'smoke-results-sdk-kotlin.json';
733
+ writeFileSync(outputPath, JSON.stringify(results, null, 2));
734
+ console.log(`\nResults written to ${outputPath}`);
735
+
736
+ // Summary
737
+ const total = exchanges.length;
738
+ console.log(`\n=== Summary ===`);
739
+ console.log(` Total: ${total}`);
740
+ console.log(` Success: ${successCount}`);
741
+ console.log(` API errors: ${errorCount}`);
742
+ console.log(` Skipped: ${skipCount}`);
743
+ if (unexpectedCount > 0) {
744
+ console.log(` Unexpected: ${unexpectedCount}`);
745
+ }
746
+ }
747
+
748
+ // ---------------------------------------------------------------------------
749
+ // Helpers
750
+ // ---------------------------------------------------------------------------
751
+
752
+ function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
753
+ return {
754
+ operationId: op.name,
755
+ service,
756
+ operationName: op.name,
757
+ request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
758
+ response: { status: 0, body: null },
759
+ outcome: 'skipped',
760
+ error: reason,
761
+ durationMs: 0,
762
+ };
763
+ }
764
+
765
+ function buildExchange(
766
+ op: Operation,
767
+ service: string,
768
+ capture: ProxyCapture,
769
+ durationMs: number,
770
+ resolution: MethodResolution,
771
+ ): CapturedExchange {
772
+ const status = capture.response.status;
773
+ const expectedCodes = getExpectedStatusCodes(op);
774
+ const unexpected = isUnexpectedStatus(status, op);
775
+
776
+ return {
777
+ operationId: op.name,
778
+ service,
779
+ operationName: op.name,
780
+ request: capture.request,
781
+ response: capture.response,
782
+ outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
783
+ unexpectedStatus: unexpected || undefined,
784
+ expectedStatusCodes: expectedCodes,
785
+ durationMs,
786
+ provenance: {
787
+ resolutionTier: resolution.tier,
788
+ resolutionConfidence: resolution.confidence,
789
+ sdkMethodName: `${resolution.service}.${resolution.method}`,
790
+ captureIndex: 0,
791
+ totalCaptures: 1,
792
+ },
793
+ };
794
+ }
795
+
796
+ main().catch((err) => {
797
+ console.error('Fatal error:', err);
798
+ process.exit(1);
799
+ });