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