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