@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,948 @@
1
+ /**
2
+ * Go SDK smoke test -- captures wire-level HTTP exchanges from the generated
3
+ * Go SDK by running a local HTTP proxy, generating a Go test program that
4
+ * calls the SDK through it, and collecting the captured request/response pairs.
5
+ *
6
+ * Usage:
7
+ * npx tsx smoke/sdk-go.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 } from 'node:path';
14
+ import { execSync, spawn } from 'node:child_process';
15
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
16
+ import {
17
+ parseSpec,
18
+ planOperations,
19
+ planWaves,
20
+ generatePayload,
21
+ IdRegistry,
22
+ delay,
23
+ parseCliArgs,
24
+ loadSmokeConfig,
25
+ getExpectedStatusCodes,
26
+ isUnexpectedStatus,
27
+ toSnakeCase,
28
+ toCamelCase,
29
+ SERVICE_PROPERTY_MAP,
30
+ } from '@workos/oagen/smoke';
31
+ import type { CapturedExchange, SmokeResults, ExchangeProvenance, OperationWave } from '@workos/oagen/smoke';
32
+ import type { Operation } from '@workos/oagen';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ interface ManifestEntry {
39
+ sdkMethod: string;
40
+ service: string;
41
+ }
42
+
43
+ interface CapturedRequest {
44
+ method: string;
45
+ path: string;
46
+ queryParams: Record<string, string>;
47
+ body: unknown | null;
48
+ }
49
+
50
+ interface CapturedResponse {
51
+ status: number;
52
+ body: unknown | null;
53
+ }
54
+
55
+ interface ProxyExchange {
56
+ request: CapturedRequest;
57
+ response: CapturedResponse;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Go naming conventions (mirror the emitter's naming.ts)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const GO_ACRONYMS = new Set([
65
+ 'ID',
66
+ 'URL',
67
+ 'API',
68
+ 'HTTP',
69
+ 'HTTPS',
70
+ 'JSON',
71
+ 'XML',
72
+ 'SQL',
73
+ 'HTML',
74
+ 'CSS',
75
+ 'URI',
76
+ 'SSO',
77
+ 'IP',
78
+ 'TLS',
79
+ 'SSL',
80
+ 'DNS',
81
+ 'TCP',
82
+ 'UDP',
83
+ 'SSH',
84
+ 'JWT',
85
+ 'OAuth',
86
+ 'SDK',
87
+ 'CLI',
88
+ 'MFA',
89
+ 'SAML',
90
+ 'SCIM',
91
+ 'DSYNC',
92
+ ]);
93
+
94
+ function goAcronyms(name: string): string {
95
+ return name.replace(/[A-Z][a-z]*/g, (segment) => {
96
+ const upper = segment.toUpperCase();
97
+ if (GO_ACRONYMS.has(upper)) return upper;
98
+ return segment;
99
+ });
100
+ }
101
+
102
+ function toPascalCase(s: string): string {
103
+ return s
104
+ .replace(/[-_]+/g, ' ')
105
+ .replace(/\s+/g, ' ')
106
+ .trim()
107
+ .split(' ')
108
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
109
+ .join('');
110
+ }
111
+
112
+ function goExportedName(name: string): string {
113
+ return goAcronyms(toPascalCase(name));
114
+ }
115
+
116
+ function goServicePackageName(name: string): string {
117
+ return toSnakeCase(name).replace(/_/g, '').toLowerCase();
118
+ }
119
+
120
+ function goFieldName(name: string): string {
121
+ return goAcronyms(toPascalCase(name));
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Manifest loading
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
129
+ const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
130
+ if (!existsSync(manifestPath)) {
131
+ console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
132
+ console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
133
+ return null;
134
+ }
135
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
136
+ const manifest = new Map<string, ManifestEntry>();
137
+ for (const [httpKey, entry] of Object.entries(raw)) {
138
+ manifest.set(httpKey, entry as ManifestEntry);
139
+ }
140
+ return manifest;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Method resolution
145
+ // ---------------------------------------------------------------------------
146
+
147
+ interface MethodResolution {
148
+ service: string;
149
+ method: string;
150
+ tier: ExchangeProvenance['resolutionTier'];
151
+ confidence: number;
152
+ }
153
+
154
+ function resolveMethod(
155
+ op: Operation,
156
+ irService: string,
157
+ manifest: Map<string, ManifestEntry> | null,
158
+ ): MethodResolution | null {
159
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
160
+
161
+ // Tier 0: Manifest match (primary for generated SDKs)
162
+ if (manifest) {
163
+ const entry = manifest.get(httpKey);
164
+ if (entry) {
165
+ return {
166
+ service: entry.service,
167
+ method: entry.sdkMethod,
168
+ tier: 'manifest',
169
+ confidence: 1.0,
170
+ };
171
+ }
172
+ }
173
+
174
+ // Tier 1: Exact match -- IR operation name in PascalCase (Go convention)
175
+ const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
176
+ const exactName = goExportedName(op.name);
177
+ return {
178
+ service: sdkProp,
179
+ method: exactName,
180
+ tier: 'exact',
181
+ confidence: 0.8,
182
+ };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Proxy server -- captures HTTP exchanges between Go process and real API
187
+ // ---------------------------------------------------------------------------
188
+
189
+ class CaptureProxy {
190
+ private exchanges: ProxyExchange[] = [];
191
+ private server: ReturnType<typeof createServer> | null = null;
192
+ private port = 0;
193
+ private targetBaseUrl: string;
194
+
195
+ constructor(targetBaseUrl: string) {
196
+ this.targetBaseUrl = targetBaseUrl.replace(/\/$/, '');
197
+ }
198
+
199
+ async start(): Promise<number> {
200
+ return new Promise((resolve, reject) => {
201
+ this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
202
+ await this.handleRequest(req, res);
203
+ });
204
+
205
+ this.server.listen(0, '127.0.0.1', () => {
206
+ const addr = this.server!.address();
207
+ if (addr && typeof addr === 'object') {
208
+ this.port = addr.port;
209
+ resolve(this.port);
210
+ } else {
211
+ reject(new Error('Failed to get proxy server address'));
212
+ }
213
+ });
214
+
215
+ this.server.on('error', reject);
216
+ });
217
+ }
218
+
219
+ async stop(): Promise<void> {
220
+ return new Promise((resolve) => {
221
+ if (this.server) {
222
+ this.server.close(() => resolve());
223
+ } else {
224
+ resolve();
225
+ }
226
+ });
227
+ }
228
+
229
+ getExchanges(): ProxyExchange[] {
230
+ return [...this.exchanges];
231
+ }
232
+
233
+ clearExchanges(): void {
234
+ this.exchanges = [];
235
+ }
236
+
237
+ getLastExchange(): ProxyExchange | null {
238
+ return this.exchanges.length > 0 ? this.exchanges[this.exchanges.length - 1] : null;
239
+ }
240
+
241
+ private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
242
+ const reqBody = await this.readBody(req);
243
+ const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`);
244
+ const method = (req.method || 'GET').toUpperCase();
245
+ const path = url.pathname;
246
+ const queryParams: Record<string, string> = {};
247
+ url.searchParams.forEach((v, k) => {
248
+ queryParams[k] = v;
249
+ });
250
+
251
+ let parsedReqBody: unknown = null;
252
+ if (reqBody) {
253
+ try {
254
+ parsedReqBody = JSON.parse(reqBody);
255
+ } catch {
256
+ parsedReqBody = reqBody;
257
+ }
258
+ }
259
+
260
+ const capturedReq: CapturedRequest = { method, path, queryParams, body: parsedReqBody };
261
+
262
+ // Forward to the real API
263
+ const targetUrl = new URL(path, this.targetBaseUrl);
264
+ url.searchParams.forEach((v, k) => {
265
+ targetUrl.searchParams.set(k, v);
266
+ });
267
+
268
+ const forwardHeaders: Record<string, string> = {};
269
+ for (const [key, value] of Object.entries(req.headers)) {
270
+ if (key === 'host' || key === 'connection') continue;
271
+ if (value) {
272
+ forwardHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
273
+ }
274
+ }
275
+
276
+ try {
277
+ const fetchInit: RequestInit = {
278
+ method,
279
+ headers: forwardHeaders,
280
+ };
281
+ if (reqBody && method !== 'GET' && method !== 'HEAD') {
282
+ fetchInit.body = reqBody;
283
+ }
284
+
285
+ const response = await fetch(targetUrl.toString(), fetchInit);
286
+
287
+ let responseBody: unknown = null;
288
+ const responseText = await response.text();
289
+ try {
290
+ responseBody = JSON.parse(responseText);
291
+ } catch {
292
+ responseBody = responseText || null;
293
+ }
294
+
295
+ // Store the captured exchange
296
+ this.exchanges.push({
297
+ request: capturedReq,
298
+ response: { status: response.status, body: responseBody },
299
+ });
300
+
301
+ // Forward response back to Go process
302
+ const respHeaders: Record<string, string> = {};
303
+ response.headers.forEach((v, k) => {
304
+ // Skip transfer-encoding since we send the full body
305
+ if (k === 'transfer-encoding') return;
306
+ respHeaders[k] = v;
307
+ });
308
+
309
+ res.writeHead(response.status, respHeaders);
310
+ res.end(responseText);
311
+ } catch (err) {
312
+ const message = err instanceof Error ? err.message : String(err);
313
+ this.exchanges.push({
314
+ request: capturedReq,
315
+ response: { status: 502, body: { error: message } },
316
+ });
317
+ res.writeHead(502, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ error: `Proxy error: ${message}` }));
319
+ }
320
+ }
321
+
322
+ private readBody(req: IncomingMessage): Promise<string> {
323
+ return new Promise((resolve, reject) => {
324
+ const chunks: Buffer[] = [];
325
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
326
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
327
+ req.on('error', reject);
328
+ });
329
+ }
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Go code generation -- produces main.go that calls SDK methods via proxy
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function detectModulePath(sdkPath: string): string {
337
+ const goModPath = resolve(sdkPath, 'go.mod');
338
+ if (existsSync(goModPath)) {
339
+ const goMod = readFileSync(goModPath, 'utf-8');
340
+ const match = goMod.match(/^module\s+(\S+)/m);
341
+ if (match) return match[1];
342
+ }
343
+ return 'github.com/workos/workos-go/v4';
344
+ }
345
+
346
+ function generateGoImports(
347
+ modulePath: string,
348
+ servicePackages: Set<string>,
349
+ needsJson: boolean,
350
+ needsServicePkg: boolean,
351
+ ): string {
352
+ const lines: string[] = [];
353
+ lines.push('import (');
354
+ lines.push('\t"context"');
355
+ if (needsJson) {
356
+ lines.push('\t"encoding/json"');
357
+ }
358
+ lines.push('\t"fmt"');
359
+ lines.push('\t"os"');
360
+ lines.push('');
361
+ lines.push(`\tworkos "${modulePath}/pkg"`);
362
+ if (needsServicePkg) {
363
+ for (const pkg of [...servicePackages].sort()) {
364
+ lines.push(`\t"${modulePath}/pkg/${pkg}"`);
365
+ }
366
+ }
367
+ lines.push(')');
368
+ return lines.join('\n');
369
+ }
370
+
371
+ function generateGoPayloadStruct(payload: Record<string, unknown>, optsType: string, servicePackage: string): string {
372
+ const lines: string[] = [];
373
+ lines.push(`${servicePackage}.${optsType}{`);
374
+ for (const [key, value] of Object.entries(payload)) {
375
+ const goField = goFieldName(key);
376
+ lines.push(`\t\t${goField}: ${goLiteral(value)},`);
377
+ }
378
+ lines.push('\t}');
379
+ return lines.join('\n');
380
+ }
381
+
382
+ function goLiteral(value: unknown): string {
383
+ if (value === null || value === undefined) return 'nil';
384
+ if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
385
+ if (typeof value === 'number') {
386
+ if (Number.isInteger(value)) return String(value);
387
+ return String(value);
388
+ }
389
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
390
+ if (Array.isArray(value)) {
391
+ if (value.length === 0) return 'nil';
392
+ // Simple string arrays
393
+ const items = value.map((v) => goLiteral(v)).join(', ');
394
+ return `[]interface{}{${items}}`;
395
+ }
396
+ if (typeof value === 'object') {
397
+ const obj = value as Record<string, unknown>;
398
+ const entries = Object.entries(obj);
399
+ if (entries.length === 0) return 'map[string]interface{}{}';
400
+ const parts = entries.map(([k, v]) => `"${k}": ${goLiteral(v)}`).join(', ');
401
+ return `map[string]interface{}{${parts}}`;
402
+ }
403
+ return `"${String(value)}"`;
404
+ }
405
+
406
+ function generateGoCallBlock(
407
+ op: Operation,
408
+ resolution: MethodResolution,
409
+ pathParams: Record<string, string>,
410
+ spec: any,
411
+ callIndex: number,
412
+ ): string {
413
+ const lines: string[] = [];
414
+ const servicePackage = goServicePackageName(resolution.service);
415
+ const method = resolution.method;
416
+
417
+ // Build arguments
418
+ const args: string[] = ['ctx'];
419
+
420
+ // Path params (positional string args)
421
+ for (const p of op.pathParams) {
422
+ args.push(`"${pathParams[p.name] || ''}"`);
423
+ }
424
+
425
+ // Request body opts struct
426
+ if (op.requestBody) {
427
+ const payload = generatePayload(op, spec);
428
+ if (payload && Object.keys(payload).length > 0) {
429
+ const optsType = `${method}Opts`;
430
+ args.push(generateGoPayloadStruct(payload, optsType, servicePackage));
431
+ }
432
+ }
433
+
434
+ // Paginated operations: pass opts with Limit=1
435
+ if (op.pagination && !op.requestBody) {
436
+ const extraParams = op.queryParams.filter((p: any) => !['limit', 'before', 'after', 'order'].includes(p.name));
437
+ if (extraParams.length > 0) {
438
+ // Match the emitter convention: List → ListFilterOpts, others → ${method}Opts
439
+ const optsType = method === 'List' ? 'ListFilterOpts' : `${method}Opts`;
440
+ args.push(`${servicePackage}.${optsType}{Limit: 1}`);
441
+ } else {
442
+ args.push(`${servicePackage}.ListOpts{Limit: 1}`);
443
+ }
444
+ }
445
+
446
+ // Determine the service accessor on the client
447
+ const serviceProp = goExportedName(resolution.service);
448
+
449
+ lines.push(`\t// Call ${callIndex}: ${op.httpMethod.toUpperCase()} ${op.path}`);
450
+ lines.push(`\tfmt.Fprintf(os.Stderr, "CALL_START:${callIndex}\\n")`);
451
+
452
+ // Determine return type: paginated and GET-with-response return (result, error),
453
+ // DELETE returns just error
454
+ const isDelete = op.httpMethod === 'delete';
455
+ const hasResponse = !isDelete;
456
+
457
+ if (hasResponse) {
458
+ lines.push(`\tresult${callIndex}, err${callIndex} := client.${serviceProp}.${method}(${args.join(', ')})`);
459
+ lines.push(`\tif err${callIndex} != nil {`);
460
+ lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_ERROR:${callIndex}:%s\\n", err${callIndex}.Error())`);
461
+ lines.push('\t} else {');
462
+ lines.push(`\t\tjsonResult${callIndex}, _ := json.Marshal(result${callIndex})`);
463
+ lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_OK:${callIndex}:%s\\n", string(jsonResult${callIndex}))`);
464
+ lines.push('\t}');
465
+ } else {
466
+ lines.push(`\terr${callIndex} := client.${serviceProp}.${method}(${args.join(', ')})`);
467
+ lines.push(`\tif err${callIndex} != nil {`);
468
+ lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_ERROR:${callIndex}:%s\\n", err${callIndex}.Error())`);
469
+ lines.push('\t} else {');
470
+ lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_OK:${callIndex}:\\n")`);
471
+ lines.push('\t}');
472
+ }
473
+
474
+ lines.push(`\tfmt.Fprintf(os.Stderr, "CALL_END:${callIndex}\\n")`);
475
+ lines.push('');
476
+
477
+ return lines.join('\n');
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Planned call type for wave-based batching
482
+ // ---------------------------------------------------------------------------
483
+
484
+ interface PlannedCall {
485
+ index: number;
486
+ op: Operation;
487
+ irService: string;
488
+ resolution: MethodResolution;
489
+ pathParams: Record<string, string>;
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Main
494
+ // ---------------------------------------------------------------------------
495
+
496
+ async function main(): Promise<void> {
497
+ const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
498
+
499
+ if (!sdkPath) {
500
+ console.error('--sdk-path is required');
501
+ process.exit(1);
502
+ }
503
+
504
+ const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
505
+ if (!apiKey) {
506
+ console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
507
+ process.exit(1);
508
+ }
509
+
510
+ // Load config
511
+ loadSmokeConfig(smokeConfig);
512
+
513
+ // Parse spec
514
+ console.log('Parsing spec...');
515
+ const spec = await parseSpec(specPath);
516
+ console.log(`Spec: ${spec.name} v${spec.version}`);
517
+
518
+ // Load manifest
519
+ const manifest = loadManifest(sdkPath);
520
+
521
+ const baseUrl = process.env.WORKOS_BASE_URL || spec.baseUrl;
522
+
523
+ // Start capture proxy
524
+ const proxy = new CaptureProxy(baseUrl);
525
+ const proxyPort = await proxy.start();
526
+ console.log(`Proxy started on port ${proxyPort}`);
527
+
528
+ // Plan operations
529
+ const groups = planOperations(spec);
530
+ const ids = new IdRegistry();
531
+ const exchanges: CapturedExchange[] = [];
532
+ const delayMs = Number(process.env.SMOKE_DELAY_MS) || 200;
533
+
534
+ let successCount = 0;
535
+ let errorCount = 0;
536
+ let skipCount = 0;
537
+ let unexpectedCount = 0;
538
+
539
+ // Temp directory for Go compilation — one main.go per wave
540
+ const tmpDir = resolve(sdkPath, '.smoke-tmp');
541
+ if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
542
+ mkdirSync(tmpDir, { recursive: true });
543
+
544
+ // Detect Go module path from SDK
545
+ const modulePath = detectModulePath(sdkPath);
546
+
547
+ // Write go.mod for the temp module that references the local SDK.
548
+ // The pseudo-version must match the major version suffix in the module path
549
+ // (e.g. /v4 requires v4.x.x).
550
+ const majorMatch = modulePath.match(/\/v(\d+)$/);
551
+ const pseudoVersion = majorMatch ? `v${majorMatch[1]}.0.0` : 'v0.0.0';
552
+ const tmpGoMod = [
553
+ 'module smoke-test-go',
554
+ '',
555
+ 'go 1.21',
556
+ '',
557
+ `require ${modulePath} ${pseudoVersion}`,
558
+ '',
559
+ `replace ${modulePath} => ${resolve(sdkPath)}`,
560
+ ].join('\n');
561
+ writeFileSync(resolve(tmpDir, 'go.mod'), tmpGoMod);
562
+ writeFileSync(resolve(tmpDir, 'go.sum'), '');
563
+
564
+ // Use wave-based planning: execute parameterless ops first, extract IDs,
565
+ // then plan the next wave of ops whose path params are now resolvable.
566
+ let globalCallIndex = 0;
567
+
568
+ const waveIterator = planWaves(groups, ids, (op, irService) => {
569
+ const resolution = resolveMethod(op, irService, manifest);
570
+ return resolution !== null;
571
+ });
572
+
573
+ let waveNumber = 0;
574
+ let waveResult = waveIterator.next();
575
+
576
+ try {
577
+ while (!waveResult.done) {
578
+ const wave: OperationWave = waveResult.value;
579
+ waveNumber++;
580
+
581
+ // Build planned calls for this wave, resolving methods
582
+ const plannedCalls: PlannedCall[] = [];
583
+ const waveSkipped: Array<{ op: Operation; irService: string; reason: string }> = [];
584
+
585
+ for (const { op, irService, pathParams } of wave.calls) {
586
+ const resolution = resolveMethod(op, irService, manifest);
587
+ if (!resolution) {
588
+ waveSkipped.push({ op, irService, reason: 'No matching SDK method' });
589
+ continue;
590
+ }
591
+ plannedCalls.push({
592
+ index: globalCallIndex++,
593
+ op,
594
+ irService,
595
+ resolution,
596
+ pathParams,
597
+ });
598
+ }
599
+
600
+ // Record skipped exchanges for this wave
601
+ for (const skip of waveSkipped) {
602
+ exchanges.push(makeSkippedExchange(skip.op, skip.irService, skip.reason));
603
+ skipCount++;
604
+ }
605
+
606
+ if (plannedCalls.length === 0) {
607
+ waveResult = waveIterator.next();
608
+ continue;
609
+ }
610
+
611
+ console.log(`\n=== Wave ${waveNumber} (${plannedCalls.length} operations) ===`);
612
+
613
+ // Collect all service packages and determine if json import is needed
614
+ const servicePackages = new Set<string>();
615
+ let needsJson = false;
616
+ let needsServicePkg = false;
617
+
618
+ for (const call of plannedCalls) {
619
+ servicePackages.add(goServicePackageName(call.resolution.service));
620
+ if (call.op.httpMethod !== 'delete') needsJson = true;
621
+ if (call.op.requestBody || call.op.pagination) needsServicePkg = true;
622
+ }
623
+
624
+ // Generate all call blocks for this wave
625
+ const callBlocks: string[] = [];
626
+ for (const call of plannedCalls) {
627
+ callBlocks.push(generateGoCallBlock(call.op, call.resolution, call.pathParams, spec, call.index));
628
+ }
629
+
630
+ const imports = generateGoImports(modulePath, servicePackages, needsJson, needsServicePkg);
631
+
632
+ const goSource = [
633
+ 'package main',
634
+ '',
635
+ imports,
636
+ '',
637
+ 'func main() {',
638
+ `\tclient := workos.NewClient("${apiKey}", workos.WithEndpoint("http://127.0.0.1:${proxyPort}"))`,
639
+ '\tctx := context.Background()',
640
+ '',
641
+ ...callBlocks,
642
+ '}',
643
+ ].join('\n');
644
+
645
+ const mainGoPath = resolve(tmpDir, 'main.go');
646
+ writeFileSync(mainGoPath, goSource);
647
+
648
+ // Clear proxy exchanges before running this wave
649
+ proxy.clearExchanges();
650
+ const waveStart = Date.now();
651
+
652
+ // Step 1: Build (sync — no proxy needed during compilation)
653
+ let buildError: string | null = null;
654
+ try {
655
+ execSync('go build -o smoke-driver main.go', {
656
+ cwd: tmpDir,
657
+ timeout: 120_000,
658
+ env: { ...process.env, GOPATH: process.env.GOPATH || resolve(process.env.HOME || '~', 'go') },
659
+ encoding: 'utf-8',
660
+ stdio: ['pipe', 'pipe', 'pipe'],
661
+ });
662
+ } catch (err: any) {
663
+ const stderr = typeof err.stderr === 'string' ? err.stderr : '';
664
+ buildError = stderr.trim().split('\n').slice(0, 5).join(' ') || 'go build failed';
665
+ }
666
+
667
+ if (buildError) {
668
+ // Build failure affects entire wave
669
+ const elapsed = Date.now() - waveStart;
670
+ for (const call of plannedCalls) {
671
+ exchanges.push({
672
+ ...makeSkippedExchange(call.op, call.irService, buildError),
673
+ outcome: 'api-error',
674
+ durationMs: elapsed,
675
+ });
676
+ errorCount++;
677
+ console.log(` X ${call.op.name} -- ${buildError}`);
678
+ }
679
+ waveResult = waveIterator.next();
680
+ continue;
681
+ }
682
+
683
+ // Step 2: Run (async — proxy needs the event loop to handle HTTP)
684
+ // Track per-call captures using stderr markers
685
+ const callResults = new Map<
686
+ number,
687
+ {
688
+ captureIndexBefore: number;
689
+ captureIndexAfter: number;
690
+ error?: string;
691
+ startTime: number;
692
+ endTime: number;
693
+ }
694
+ >();
695
+
696
+ let currentCallStart = Date.now();
697
+ let currentCapturesBefore = 0;
698
+
699
+ try {
700
+ await new Promise<void>((resolveRun, rejectRun) => {
701
+ const child = spawn(resolve(tmpDir, 'smoke-driver'), [], {
702
+ cwd: tmpDir,
703
+ env: process.env,
704
+ });
705
+
706
+ let stderrBuf = '';
707
+
708
+ child.stderr.on('data', (data: Buffer) => {
709
+ stderrBuf += data.toString();
710
+ const lines = stderrBuf.split('\n');
711
+ stderrBuf = lines.pop() || '';
712
+
713
+ const proxyExchanges = proxy.getExchanges();
714
+
715
+ for (const line of lines) {
716
+ const trimmed = line.trim();
717
+ if (trimmed.startsWith('CALL_START:')) {
718
+ currentCallStart = Date.now();
719
+ currentCapturesBefore = proxyExchanges.length;
720
+ } else if (trimmed.startsWith('CALL_OK:')) {
721
+ const rest = trimmed.slice('CALL_OK:'.length);
722
+ const colonIdx = rest.indexOf(':');
723
+ const idx = parseInt(rest.slice(0, colonIdx), 10);
724
+ if (callResults.has(idx)) continue;
725
+ callResults.set(idx, {
726
+ captureIndexBefore: currentCapturesBefore,
727
+ captureIndexAfter: proxy.getExchanges().length,
728
+ startTime: currentCallStart,
729
+ endTime: Date.now(),
730
+ });
731
+ } else if (trimmed.startsWith('CALL_ERROR:')) {
732
+ const rest = trimmed.slice('CALL_ERROR:'.length);
733
+ const colonIdx = rest.indexOf(':');
734
+ const idx = parseInt(rest.slice(0, colonIdx), 10);
735
+ const errMsg = rest.slice(colonIdx + 1);
736
+ if (callResults.has(idx)) continue;
737
+ callResults.set(idx, {
738
+ captureIndexBefore: currentCapturesBefore,
739
+ captureIndexAfter: proxy.getExchanges().length,
740
+ error: errMsg,
741
+ startTime: currentCallStart,
742
+ endTime: Date.now(),
743
+ });
744
+ } else if (trimmed.startsWith('CALL_END:')) {
745
+ const idx = parseInt(trimmed.slice('CALL_END:'.length), 10);
746
+ const existing = callResults.get(idx);
747
+ if (existing) {
748
+ existing.captureIndexAfter = proxy.getExchanges().length;
749
+ existing.endTime = Date.now();
750
+ }
751
+ }
752
+ }
753
+ });
754
+
755
+ const timeout = setTimeout(() => {
756
+ child.kill('SIGKILL');
757
+ rejectRun(new Error('Go binary timed out after 60s'));
758
+ }, 60_000);
759
+
760
+ child.on('close', () => {
761
+ clearTimeout(timeout);
762
+ // Process any remaining stderr
763
+ if (stderrBuf.trim()) {
764
+ const trimmed = stderrBuf.trim();
765
+ const proxyExchanges = proxy.getExchanges();
766
+ if (trimmed.startsWith('CALL_END:')) {
767
+ const idx = parseInt(trimmed.slice('CALL_END:'.length), 10);
768
+ const existing = callResults.get(idx);
769
+ if (existing) {
770
+ existing.captureIndexAfter = proxyExchanges.length;
771
+ existing.endTime = Date.now();
772
+ }
773
+ }
774
+ }
775
+ resolveRun();
776
+ });
777
+
778
+ child.on('error', (err) => {
779
+ clearTimeout(timeout);
780
+ rejectRun(err);
781
+ });
782
+ });
783
+ } catch (err) {
784
+ const message = err instanceof Error ? err.message : String(err);
785
+ console.error(`Wave ${waveNumber} execution error: ${message}`);
786
+ }
787
+
788
+ await delay(delayMs);
789
+
790
+ // Process results for this wave — extract IDs so the next wave can use them
791
+ const proxyExchanges = proxy.getExchanges();
792
+
793
+ for (const call of plannedCalls) {
794
+ const { index, op, irService, resolution } = call;
795
+ const isTopLevel = op.pathParams.length === 0;
796
+ const result = callResults.get(index);
797
+
798
+ if (!result) {
799
+ exchanges.push({
800
+ ...makeSkippedExchange(op, irService, 'Call did not execute (binary may have failed)'),
801
+ outcome: 'api-error',
802
+ durationMs: 0,
803
+ });
804
+ errorCount++;
805
+ console.log(` X ${op.name} -- did not execute`);
806
+ continue;
807
+ }
808
+
809
+ const elapsed = result.endTime - result.startTime;
810
+
811
+ if (result.captureIndexAfter <= result.captureIndexBefore) {
812
+ if (result.error) {
813
+ exchanges.push({
814
+ ...makeSkippedExchange(op, irService, result.error),
815
+ outcome: 'api-error',
816
+ durationMs: elapsed,
817
+ });
818
+ errorCount++;
819
+ console.log(` X ${op.name} -- ${result.error.split('\n')[0]}`);
820
+ } else {
821
+ exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
822
+ skipCount++;
823
+ console.log(` SKIP ${op.name} -- no HTTP capture`);
824
+ }
825
+ continue;
826
+ }
827
+
828
+ const captured = proxyExchanges[result.captureIndexAfter - 1];
829
+ const exchange = buildExchange(op, irService, captured, elapsed, resolution);
830
+
831
+ if (result.error) {
832
+ exchange.error = result.error;
833
+ }
834
+
835
+ // Extract IDs from response (critical: feeds the next wave)
836
+ ids.extractAndStore(irService, captured.response.body, isTopLevel);
837
+
838
+ if (exchange.unexpectedStatus) {
839
+ unexpectedCount++;
840
+ console.log(` ! ${op.name} -> ${captured.response.status} (unexpected)`);
841
+ } else if (exchange.outcome === 'api-error') {
842
+ errorCount++;
843
+ console.log(` X ${op.name} -> ${captured.response.status}`);
844
+ } else {
845
+ successCount++;
846
+ console.log(` OK ${op.name} -> ${captured.response.status} (${elapsed}ms)`);
847
+ }
848
+
849
+ exchanges.push(exchange);
850
+ }
851
+
852
+ // Advance to the next wave (IDs from this wave are now in the registry)
853
+ waveResult = waveIterator.next();
854
+ }
855
+
856
+ // Record any operations that could never be resolved
857
+ if (waveResult.done && waveResult.value) {
858
+ for (const unresolved of waveResult.value) {
859
+ exchanges.push(makeSkippedExchange(unresolved.operation, unresolved.service, 'Missing path param IDs'));
860
+ skipCount++;
861
+ }
862
+ }
863
+ } finally {
864
+ await proxy.stop();
865
+ // Clean up temp directory
866
+ try {
867
+ rmSync(tmpDir, { recursive: true });
868
+ } catch {
869
+ // best effort
870
+ }
871
+ }
872
+
873
+ // Write results
874
+ const results: SmokeResults = {
875
+ source: 'sdk-go',
876
+ timestamp: new Date().toISOString(),
877
+ specVersion: spec.version,
878
+ exchanges,
879
+ };
880
+
881
+ const outputPath = 'smoke-results-sdk-go.json';
882
+ writeFileSync(outputPath, JSON.stringify(results, null, 2));
883
+ console.log(`\nResults written to ${outputPath}`);
884
+
885
+ // Summary
886
+ const total = exchanges.length;
887
+ console.log(`\n=== Summary ===`);
888
+ console.log(` Total: ${total}`);
889
+ console.log(` Success: ${successCount}`);
890
+ console.log(` API errors: ${errorCount}`);
891
+ console.log(` Skipped: ${skipCount}`);
892
+ if (unexpectedCount > 0) {
893
+ console.log(` Unexpected: ${unexpectedCount}`);
894
+ }
895
+ }
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // Helpers
899
+ // ---------------------------------------------------------------------------
900
+
901
+ function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
902
+ return {
903
+ operationId: op.name,
904
+ service,
905
+ operationName: op.name,
906
+ request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
907
+ response: { status: 0, body: null },
908
+ outcome: 'skipped',
909
+ error: reason,
910
+ durationMs: 0,
911
+ };
912
+ }
913
+
914
+ function buildExchange(
915
+ op: Operation,
916
+ service: string,
917
+ capture: ProxyExchange,
918
+ durationMs: number,
919
+ resolution: MethodResolution,
920
+ ): CapturedExchange {
921
+ const status = capture.response.status;
922
+ const expectedCodes = getExpectedStatusCodes(op);
923
+ const unexpected = isUnexpectedStatus(status, op);
924
+
925
+ return {
926
+ operationId: op.name,
927
+ service,
928
+ operationName: op.name,
929
+ request: capture.request,
930
+ response: capture.response,
931
+ outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
932
+ unexpectedStatus: unexpected || undefined,
933
+ expectedStatusCodes: expectedCodes,
934
+ durationMs,
935
+ provenance: {
936
+ resolutionTier: resolution.tier,
937
+ resolutionConfidence: resolution.confidence,
938
+ sdkMethodName: `${resolution.service}.${resolution.method}`,
939
+ captureIndex: 0,
940
+ totalCaptures: 1,
941
+ },
942
+ };
943
+ }
944
+
945
+ main().catch((err) => {
946
+ console.error('Fatal error:', err);
947
+ process.exit(1);
948
+ });