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