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