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