@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,699 @@
1
+ /**
2
+ * PHP SDK smoke test -- captures wire-level HTTP exchanges from the generated
3
+ * WorkOS PHP SDK via a local HTTP proxy and outputs SmokeResults JSON for diff
4
+ * comparison.
5
+ *
6
+ * Uses a batched approach: generates ONE PHP script that calls ALL operations
7
+ * sequentially, eliminating per-operation cold start overhead. Uses `spawn`
8
+ * (async) so the proxy event loop can process requests concurrently.
9
+ *
10
+ * Usage:
11
+ * npx tsx smoke/sdk-php.ts --spec ../openapi-spec/spec/open-api-spec.yaml --sdk-path ./sdk
12
+ *
13
+ * Requires API_KEY or WORKOS_API_KEY env var and `php` on $PATH.
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
17
+ import { resolve, join } from 'node:path';
18
+ import { execSync, spawn } from 'node:child_process';
19
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
20
+ import {
21
+ parseSpec,
22
+ planOperations,
23
+ planWaves,
24
+ generatePayload,
25
+ generateQueryParams,
26
+ IdRegistry,
27
+ delay,
28
+ parseCliArgs,
29
+ loadSmokeConfig,
30
+ getExpectedStatusCodes,
31
+ isUnexpectedStatus,
32
+ toCamelCase,
33
+ SERVICE_PROPERTY_MAP,
34
+ } from '@workos/oagen/smoke';
35
+ import type { CapturedExchange, SmokeResults, ExchangeProvenance, OperationWave } from '@workos/oagen/smoke';
36
+ import type { Operation } from '@workos/oagen';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ interface ManifestEntry {
43
+ sdkMethod: string;
44
+ service: string;
45
+ }
46
+
47
+ interface CapturedRequest {
48
+ method: string;
49
+ path: string;
50
+ queryParams: Record<string, string>;
51
+ body: unknown | null;
52
+ }
53
+
54
+ interface CapturedResponse {
55
+ status: number;
56
+ body: unknown | null;
57
+ }
58
+
59
+ interface ProxyCapture {
60
+ request: CapturedRequest;
61
+ response: CapturedResponse;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // HTTP Proxy
66
+ // ---------------------------------------------------------------------------
67
+
68
+ class CaptureProxy {
69
+ private server: ReturnType<typeof createServer> | null = null;
70
+ port = 0;
71
+ captures: ProxyCapture[] = [];
72
+
73
+ constructor(
74
+ private targetBaseUrl: string,
75
+ private apiKey: string,
76
+ ) {}
77
+
78
+ async start(): Promise<number> {
79
+ return new Promise((resolvePort) => {
80
+ this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
81
+ await this.handleRequest(req, res);
82
+ });
83
+
84
+ this.server.listen(0, '127.0.0.1', () => {
85
+ const addr = this.server!.address();
86
+ this.port = typeof addr === 'object' && addr ? addr.port : 0;
87
+ resolvePort(this.port);
88
+ });
89
+ });
90
+ }
91
+
92
+ stop(): void {
93
+ this.server?.close();
94
+ }
95
+
96
+ private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
97
+ const chunks: Buffer[] = [];
98
+ for await (const chunk of req) {
99
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
100
+ }
101
+ const rawBody = Buffer.concat(chunks).toString('utf-8');
102
+
103
+ const inUrl = new URL(req.url ?? '/', `http://127.0.0.1:${this.port}`);
104
+ const method = (req.method ?? 'GET').toUpperCase();
105
+ const path = inUrl.pathname;
106
+ const queryParams: Record<string, string> = {};
107
+ inUrl.searchParams.forEach((v, k) => {
108
+ queryParams[k] = v;
109
+ });
110
+
111
+ let requestBody: unknown = null;
112
+ if (rawBody) {
113
+ try {
114
+ requestBody = JSON.parse(rawBody);
115
+ } catch {
116
+ requestBody = rawBody;
117
+ }
118
+ }
119
+
120
+ const targetUrl = new URL(path, this.targetBaseUrl);
121
+ inUrl.searchParams.forEach((v, k) => targetUrl.searchParams.set(k, v));
122
+
123
+ const forwardHeaders: Record<string, string> = {
124
+ 'Content-Type': 'application/json',
125
+ Authorization: `Bearer ${this.apiKey}`,
126
+ 'User-Agent': 'workos-php-smoke/1.0',
127
+ };
128
+
129
+ const fetchInit: RequestInit = { method, headers: forwardHeaders };
130
+ if (rawBody && method !== 'GET' && method !== 'HEAD') {
131
+ fetchInit.body = rawBody;
132
+ }
133
+
134
+ let responseStatus = 502;
135
+ let responseBody: unknown = null;
136
+ let responseRaw = '';
137
+
138
+ try {
139
+ const upstream = await fetch(targetUrl.toString(), fetchInit);
140
+ responseStatus = upstream.status;
141
+ responseRaw = await upstream.text();
142
+ try {
143
+ responseBody = JSON.parse(responseRaw);
144
+ } catch {
145
+ responseBody = responseRaw || null;
146
+ }
147
+ } catch (err) {
148
+ responseStatus = 502;
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ responseBody = { error: message };
151
+ responseRaw = JSON.stringify(responseBody);
152
+ }
153
+
154
+ this.captures.push({
155
+ request: { method, path, queryParams, body: requestBody },
156
+ response: { status: responseStatus, body: responseBody },
157
+ });
158
+
159
+ res.writeHead(responseStatus, { 'Content-Type': 'application/json' });
160
+ res.end(responseRaw);
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Manifest loading
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
169
+ const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
170
+ if (!existsSync(manifestPath)) {
171
+ console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
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
+ // Method resolution
184
+ // ---------------------------------------------------------------------------
185
+
186
+ interface MethodResolution {
187
+ className: string;
188
+ method: string;
189
+ tier: ExchangeProvenance['resolutionTier'];
190
+ confidence: number;
191
+ }
192
+
193
+ function resolveMethod(
194
+ op: Operation,
195
+ irService: string,
196
+ manifest: Map<string, ManifestEntry> | null,
197
+ ): MethodResolution | null {
198
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
199
+
200
+ if (manifest) {
201
+ const entry = manifest.get(httpKey);
202
+ if (entry) {
203
+ const className = entry.service.charAt(0).toUpperCase() + entry.service.slice(1);
204
+ return { className, method: entry.sdkMethod, tier: 'manifest', confidence: 1.0 };
205
+ }
206
+ }
207
+
208
+ const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
209
+ const className = sdkProp.charAt(0).toUpperCase() + sdkProp.slice(1);
210
+ return { className, method: toCamelCase(op.name), tier: 'exact', confidence: 0.8 };
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // PHP literal helpers
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function phpArrayLiteral(obj: unknown): string {
218
+ if (obj === null || obj === undefined) return 'null';
219
+ if (typeof obj === 'boolean') return obj ? 'true' : 'false';
220
+ if (typeof obj === 'number') return String(obj);
221
+ if (typeof obj === 'string') return `'${escapePhpString(obj)}'`;
222
+ if (Array.isArray(obj)) {
223
+ if (obj.length === 0) return '[]';
224
+ return `[${obj.map((item) => phpArrayLiteral(item)).join(', ')}]`;
225
+ }
226
+ if (typeof obj === 'object') {
227
+ const entries = Object.entries(obj);
228
+ if (entries.length === 0) return '[]';
229
+ return `[${entries.map(([k, v]) => `'${escapePhpString(k)}' => ${phpArrayLiteral(v)}`).join(', ')}]`;
230
+ }
231
+ return String(obj);
232
+ }
233
+
234
+ function escapePhpString(s: string): string {
235
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Batched PHP script generation
240
+ // ---------------------------------------------------------------------------
241
+
242
+ interface PlannedCall {
243
+ index: number;
244
+ op: Operation;
245
+ irService: string;
246
+ resolution: MethodResolution;
247
+ pathParams: Record<string, string>;
248
+ }
249
+
250
+ function buildBatchedPhpScript(
251
+ sdkPath: string,
252
+ namespace: string,
253
+ apiKey: string,
254
+ proxyPort: number,
255
+ calls: PlannedCall[],
256
+ spec: any,
257
+ ): string {
258
+ const lines: string[] = [];
259
+ lines.push('<?php');
260
+ lines.push('');
261
+
262
+ // Autoloader
263
+ const autoloadPath = resolve(sdkPath, 'vendor/autoload.php').replace(/\\/g, '/');
264
+ const libPath = resolve(sdkPath, 'lib').replace(/\\/g, '/');
265
+
266
+ if (existsSync(autoloadPath)) {
267
+ lines.push(`require_once '${autoloadPath}';`);
268
+ } else {
269
+ lines.push(`spl_autoload_register(function ($class) {`);
270
+ lines.push(` $prefix = '${namespace}\\\\';`);
271
+ lines.push(` $baseDir = '${libPath}/${namespace}/';`);
272
+ lines.push(` $len = strlen($prefix);`);
273
+ lines.push(` if (strncmp($prefix, $class, $len) !== 0) { return; }`);
274
+ lines.push(` $relativeClass = substr($class, $len);`);
275
+ lines.push(` $file = $baseDir . str_replace('\\\\', '/', $relativeClass) . '.php';`);
276
+ lines.push(` if (file_exists($file)) { require $file; }`);
277
+ lines.push(`});`);
278
+ }
279
+ lines.push('');
280
+
281
+ // Configure SDK
282
+ lines.push(`${namespace}\\Client::setApiKey('${apiKey}');`);
283
+ lines.push(`${namespace}\\Client::setBaseUrl('http://127.0.0.1:${proxyPort}');`);
284
+ lines.push('');
285
+
286
+ for (const call of calls) {
287
+ const { index, op, resolution, pathParams } = call;
288
+
289
+ // Marker: start
290
+ lines.push(`fwrite(STDERR, "OAGEN_CALL_START:${index}\\n");`);
291
+
292
+ // Build arguments
293
+ const phpArgs: string[] = [];
294
+
295
+ for (const p of op.pathParams) {
296
+ const value = pathParams[p.name] ?? '';
297
+ phpArgs.push(`'${escapePhpString(value)}'`);
298
+ }
299
+
300
+ if (op.requestBody) {
301
+ const payload = generatePayload(op, spec);
302
+ if (payload && Object.keys(payload).length > 0) {
303
+ phpArgs.push(phpArrayLiteral(payload));
304
+ } else {
305
+ phpArgs.push('[]');
306
+ }
307
+ }
308
+
309
+ if (!op.requestBody && op.queryParams.some((p) => p.required)) {
310
+ const queryOpts = generateQueryParams(op, spec);
311
+ if (Object.keys(queryOpts).length > 0) {
312
+ phpArgs.push(phpArrayLiteral(queryOpts));
313
+ }
314
+ }
315
+
316
+ if (op.pagination && phpArgs.length === 0) {
317
+ phpArgs.push("['limit' => 1]");
318
+ } else if (op.pagination && !op.requestBody) {
319
+ const last = phpArgs[phpArgs.length - 1];
320
+ if (last && last.startsWith('[')) {
321
+ phpArgs[phpArgs.length - 1] = last.replace(/\]$/, ", 'limit' => 1]");
322
+ } else {
323
+ phpArgs.push("['limit' => 1]");
324
+ }
325
+ }
326
+
327
+ lines.push('try {');
328
+ lines.push(` $result = ${namespace}\\${resolution.className}::${resolution.method}(${phpArgs.join(', ')});`);
329
+ lines.push(` fwrite(STDERR, "OAGEN_CALL_OK:${index}\\n");`);
330
+ lines.push('} catch (\\Throwable $e) {');
331
+ lines.push(` fwrite(STDERR, "OAGEN_CALL_ERROR:${index}:" . $e->getMessage() . "\\n");`);
332
+ lines.push('}');
333
+ lines.push(`fwrite(STDERR, "OAGEN_CALL_END:${index}\\n");`);
334
+ lines.push('usleep(50000);'); // 50ms
335
+ lines.push('');
336
+ }
337
+
338
+ return lines.join('\n');
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Main
343
+ // ---------------------------------------------------------------------------
344
+
345
+ async function main(): Promise<void> {
346
+ const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
347
+
348
+ if (!sdkPath) {
349
+ console.error('--sdk-path is required');
350
+ process.exit(1);
351
+ }
352
+
353
+ const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
354
+ if (!apiKey) {
355
+ console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
356
+ process.exit(1);
357
+ }
358
+
359
+ try {
360
+ execSync('php --version', { stdio: 'pipe' });
361
+ } catch {
362
+ console.error('PHP is not available on $PATH.');
363
+ process.exit(1);
364
+ }
365
+
366
+ loadSmokeConfig(smokeConfig);
367
+
368
+ console.log('Parsing spec...');
369
+ const spec = await parseSpec(specPath);
370
+ console.log(`Spec: ${spec.name} v${spec.version}`);
371
+
372
+ const manifest = loadManifest(sdkPath);
373
+
374
+ // Detect PHP namespace
375
+ const namespace = detectNamespace(sdkPath);
376
+ console.log(`PHP namespace: ${namespace}`);
377
+
378
+ const baseUrl = process.env.WORKOS_BASE_URL || spec.baseUrl;
379
+ const proxy = new CaptureProxy(baseUrl, apiKey);
380
+ const proxyPort = await proxy.start();
381
+ console.log(`Proxy on 127.0.0.1:${proxyPort}`);
382
+
383
+ const tmpDir = join(resolve(sdkPath), '.smoke-tmp');
384
+ if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
385
+
386
+ const groups = planOperations(spec);
387
+ const ids = new IdRegistry();
388
+ const exchanges: CapturedExchange[] = [];
389
+
390
+ let successCount = 0;
391
+ let errorCount = 0;
392
+ let skipCount = 0;
393
+ let unexpectedCount = 0;
394
+
395
+ // Use wave-based planning: execute parameterless ops first, extract IDs,
396
+ // then plan the next wave of ops whose path params are now resolvable.
397
+ let globalCallIndex = 0;
398
+
399
+ const waveIterator = planWaves(groups, ids, (op, irService) => {
400
+ const resolution = resolveMethod(op, irService, manifest);
401
+ return resolution !== null;
402
+ });
403
+
404
+ let waveNumber = 0;
405
+ let waveResult = waveIterator.next();
406
+
407
+ while (!waveResult.done) {
408
+ const wave: OperationWave = waveResult.value;
409
+ waveNumber++;
410
+
411
+ // Build planned calls for this wave
412
+ const plannedCalls: PlannedCall[] = [];
413
+ const waveSkipped: Array<{ op: Operation; irService: string; reason: string }> = [];
414
+
415
+ for (const { op, irService, pathParams } of wave.calls) {
416
+ const resolution = resolveMethod(op, irService, manifest);
417
+ if (!resolution) {
418
+ waveSkipped.push({ op, irService, reason: 'No matching SDK method' });
419
+ continue;
420
+ }
421
+ plannedCalls.push({
422
+ index: globalCallIndex++,
423
+ op,
424
+ irService,
425
+ resolution,
426
+ pathParams,
427
+ });
428
+ }
429
+
430
+ for (const skip of waveSkipped) {
431
+ exchanges.push(makeSkippedExchange(skip.op, skip.irService, skip.reason));
432
+ skipCount++;
433
+ }
434
+
435
+ if (plannedCalls.length === 0) {
436
+ waveResult = waveIterator.next();
437
+ continue;
438
+ }
439
+
440
+ console.log(`\n=== Wave ${waveNumber} (${plannedCalls.length} operations) ===`);
441
+
442
+ // Generate batched PHP script for this wave
443
+ const phpScript = buildBatchedPhpScript(resolve(sdkPath), namespace, apiKey, proxyPort, plannedCalls, spec);
444
+
445
+ const scriptPath = join(tmpDir, `smoke_wave_${waveNumber}.php`);
446
+ writeFileSync(scriptPath, phpScript, 'utf-8');
447
+
448
+ const callResults = new Map<
449
+ number,
450
+ {
451
+ captureIndexBefore: number;
452
+ captureIndexAfter: number;
453
+ error?: string;
454
+ startTime: number;
455
+ endTime: number;
456
+ }
457
+ >();
458
+
459
+ let currentCapturesBefore = 0;
460
+ let currentCallStart = Date.now();
461
+
462
+ try {
463
+ await new Promise<void>((resolvePromise, rejectPromise) => {
464
+ const child = spawn('php', [scriptPath], {
465
+ stdio: ['pipe', 'pipe', 'pipe'],
466
+ });
467
+
468
+ const timeout = setTimeout(() => {
469
+ child.kill('SIGKILL');
470
+ rejectPromise(new Error('Batch PHP script timed out after 300s'));
471
+ }, 300_000);
472
+
473
+ let stderrBuf = '';
474
+
475
+ child.stderr.on('data', (data: Buffer) => {
476
+ stderrBuf += data.toString();
477
+ const lines = stderrBuf.split('\n');
478
+ stderrBuf = lines.pop() || '';
479
+
480
+ for (const line of lines) {
481
+ const trimmed = line.trim();
482
+ if (trimmed.startsWith('OAGEN_CALL_START:')) {
483
+ currentCallStart = Date.now();
484
+ currentCapturesBefore = proxy.captures.length;
485
+ } else if (trimmed.startsWith('OAGEN_CALL_OK:')) {
486
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_OK:'.length), 10);
487
+ if (!callResults.has(idx)) {
488
+ callResults.set(idx, {
489
+ captureIndexBefore: currentCapturesBefore,
490
+ captureIndexAfter: proxy.captures.length,
491
+ startTime: currentCallStart,
492
+ endTime: Date.now(),
493
+ });
494
+ }
495
+ } else if (trimmed.startsWith('OAGEN_CALL_ERROR:')) {
496
+ const rest = trimmed.slice('OAGEN_CALL_ERROR:'.length);
497
+ const colonIdx = rest.indexOf(':');
498
+ const idx = parseInt(rest.slice(0, colonIdx), 10);
499
+ const errMsg = rest.slice(colonIdx + 1);
500
+ if (!callResults.has(idx)) {
501
+ callResults.set(idx, {
502
+ captureIndexBefore: currentCapturesBefore,
503
+ captureIndexAfter: proxy.captures.length,
504
+ error: errMsg,
505
+ startTime: currentCallStart,
506
+ endTime: Date.now(),
507
+ });
508
+ }
509
+ } else if (trimmed.startsWith('OAGEN_CALL_END:')) {
510
+ const idx = parseInt(trimmed.slice('OAGEN_CALL_END:'.length), 10);
511
+ const existing = callResults.get(idx);
512
+ if (existing) {
513
+ existing.captureIndexAfter = proxy.captures.length;
514
+ existing.endTime = Date.now();
515
+ }
516
+ }
517
+ }
518
+ });
519
+
520
+ child.on('close', () => {
521
+ clearTimeout(timeout);
522
+ resolvePromise();
523
+ });
524
+
525
+ child.on('error', (err) => {
526
+ clearTimeout(timeout);
527
+ rejectPromise(err);
528
+ });
529
+ });
530
+ } catch (err) {
531
+ console.error(`Batch error: ${err instanceof Error ? err.message : String(err)}`);
532
+ }
533
+
534
+ await delay(200);
535
+
536
+ // Process results for this wave — extract IDs so the next wave can use them
537
+ for (const call of plannedCalls) {
538
+ const { index, op, irService, resolution } = call;
539
+ const isTopLevel = op.pathParams.length === 0;
540
+ const result = callResults.get(index);
541
+
542
+ if (!result) {
543
+ exchanges.push({
544
+ ...makeSkippedExchange(op, irService, 'Call did not execute'),
545
+ outcome: 'api-error',
546
+ durationMs: 0,
547
+ });
548
+ errorCount++;
549
+ console.log(` x ${op.name} -- did not execute`);
550
+ continue;
551
+ }
552
+
553
+ const elapsed = result.endTime - result.startTime;
554
+
555
+ if (result.captureIndexAfter <= result.captureIndexBefore) {
556
+ if (result.error) {
557
+ exchanges.push({
558
+ ...makeSkippedExchange(op, irService, result.error),
559
+ outcome: 'api-error',
560
+ durationMs: elapsed,
561
+ });
562
+ errorCount++;
563
+ console.log(` x ${op.name} -- ${result.error.split('\n')[0]}`);
564
+ } else {
565
+ exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
566
+ skipCount++;
567
+ console.log(` SKIP ${op.name} -- no HTTP capture`);
568
+ }
569
+ continue;
570
+ }
571
+
572
+ const capture = proxy.captures[result.captureIndexAfter - 1];
573
+ const exchange = buildExchange(op, irService, capture, elapsed, resolution);
574
+ if (result.error) exchange.error = result.error;
575
+
576
+ // Extract IDs from response (critical: feeds the next wave)
577
+ ids.extractAndStore(irService, capture.response.body, isTopLevel);
578
+
579
+ if (exchange.unexpectedStatus) {
580
+ unexpectedCount++;
581
+ console.log(` ! ${op.name} -> ${capture.response.status} (unexpected)`);
582
+ } else if (exchange.outcome === 'api-error') {
583
+ errorCount++;
584
+ console.log(` x ${op.name} -> ${capture.response.status}`);
585
+ } else {
586
+ successCount++;
587
+ console.log(` ok ${op.name} -> ${capture.response.status} (${elapsed}ms)`);
588
+ }
589
+
590
+ exchanges.push(exchange);
591
+ }
592
+
593
+ // Advance to the next wave (IDs from this wave are now in the registry)
594
+ waveResult = waveIterator.next();
595
+ }
596
+
597
+ // Record any operations that could never be resolved
598
+ if (waveResult.done && waveResult.value) {
599
+ for (const unresolved of waveResult.value) {
600
+ exchanges.push(makeSkippedExchange(unresolved.operation, unresolved.service, 'Missing path param IDs'));
601
+ skipCount++;
602
+ }
603
+ }
604
+
605
+ proxy.stop();
606
+
607
+ // Cleanup
608
+ try {
609
+ const { rmSync } = await import('node:fs');
610
+ rmSync(tmpDir, { recursive: true, force: true });
611
+ } catch {
612
+ // Ignore
613
+ }
614
+
615
+ // Write results
616
+ const results: SmokeResults = {
617
+ source: 'sdk-php',
618
+ timestamp: new Date().toISOString(),
619
+ specVersion: spec.version,
620
+ exchanges,
621
+ };
622
+
623
+ writeFileSync('smoke-results-sdk-php.json', JSON.stringify(results, null, 2));
624
+ console.log(`\nResults written to smoke-results-sdk-php.json`);
625
+
626
+ console.log(`\n=== Summary ===`);
627
+ console.log(` Total: ${exchanges.length}`);
628
+ console.log(` Success: ${successCount}`);
629
+ console.log(` API errors: ${errorCount}`);
630
+ console.log(` Skipped: ${skipCount}`);
631
+ if (unexpectedCount > 0) console.log(` Unexpected: ${unexpectedCount}`);
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Helpers
636
+ // ---------------------------------------------------------------------------
637
+
638
+ function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
639
+ return {
640
+ operationId: op.name,
641
+ service,
642
+ operationName: op.name,
643
+ request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
644
+ response: { status: 0, body: null },
645
+ outcome: 'skipped',
646
+ error: reason,
647
+ durationMs: 0,
648
+ };
649
+ }
650
+
651
+ function buildExchange(
652
+ op: Operation,
653
+ service: string,
654
+ capture: ProxyCapture,
655
+ durationMs: number,
656
+ resolution: MethodResolution,
657
+ ): CapturedExchange {
658
+ const status = capture.response.status;
659
+ return {
660
+ operationId: op.name,
661
+ service,
662
+ operationName: op.name,
663
+ request: capture.request,
664
+ response: capture.response,
665
+ outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
666
+ unexpectedStatus: isUnexpectedStatus(status, op) || undefined,
667
+ expectedStatusCodes: getExpectedStatusCodes(op),
668
+ durationMs,
669
+ provenance: {
670
+ resolutionTier: resolution.tier,
671
+ resolutionConfidence: resolution.confidence,
672
+ sdkMethodName: `${resolution.className}::${resolution.method}`,
673
+ captureIndex: 0,
674
+ totalCaptures: 1,
675
+ },
676
+ };
677
+ }
678
+
679
+ function detectNamespace(sdkPath: string): string {
680
+ const composerPath = resolve(sdkPath, 'composer.json');
681
+ if (existsSync(composerPath)) {
682
+ try {
683
+ const composer = JSON.parse(readFileSync(composerPath, 'utf-8'));
684
+ const psr4 = composer?.autoload?.['psr-4'];
685
+ if (psr4) {
686
+ const firstKey = Object.keys(psr4)[0];
687
+ if (firstKey) return firstKey.replace(/\\+$/, '');
688
+ }
689
+ } catch {
690
+ // Fall through
691
+ }
692
+ }
693
+ return 'workos';
694
+ }
695
+
696
+ main().catch((err) => {
697
+ console.error('Fatal error:', err);
698
+ process.exit(1);
699
+ });