@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,903 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* .NET SDK smoke test — captures wire-level HTTP exchanges from the generated
|
|
4
|
+
* .NET SDK and outputs SmokeResults JSON for diff comparison.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx tsx smoke/sdk-dotnet.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, readdirSync } 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
|
+
generateCamelPayload,
|
|
22
|
+
generateCamelQueryParams,
|
|
23
|
+
IdRegistry,
|
|
24
|
+
delay,
|
|
25
|
+
parseCliArgs,
|
|
26
|
+
loadSmokeConfig,
|
|
27
|
+
getExpectedStatusCodes,
|
|
28
|
+
isUnexpectedStatus,
|
|
29
|
+
toCamelCase,
|
|
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(apiKey: string, captures: ProxyCapture[]): Promise<{ port: number; close: () => void }> {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
75
|
+
const chunks: Buffer[] = [];
|
|
76
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
77
|
+
req.on('end', () => {
|
|
78
|
+
let body: unknown = null;
|
|
79
|
+
if (chunks.length > 0) {
|
|
80
|
+
try {
|
|
81
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
82
|
+
} catch {
|
|
83
|
+
body = Buffer.concat(chunks).toString();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const url = new URL(req.url!, `http://localhost`);
|
|
87
|
+
const queryParams: Record<string, string> = {};
|
|
88
|
+
url.searchParams.forEach((v, k) => {
|
|
89
|
+
queryParams[k] = v;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const options = {
|
|
93
|
+
hostname: 'api.workos.com',
|
|
94
|
+
port: 443,
|
|
95
|
+
path: req.url,
|
|
96
|
+
method: req.method,
|
|
97
|
+
headers: {
|
|
98
|
+
...req.headers,
|
|
99
|
+
host: 'api.workos.com',
|
|
100
|
+
authorization: `Bearer ${apiKey}`,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const proxyReq = httpsRequest(options, (proxyRes) => {
|
|
105
|
+
const resChunks: Buffer[] = [];
|
|
106
|
+
proxyRes.on('data', (c: Buffer) => resChunks.push(c));
|
|
107
|
+
proxyRes.on('end', () => {
|
|
108
|
+
let resBody: unknown = null;
|
|
109
|
+
if (resChunks.length > 0) {
|
|
110
|
+
try {
|
|
111
|
+
resBody = JSON.parse(Buffer.concat(resChunks).toString());
|
|
112
|
+
} catch {
|
|
113
|
+
resBody = Buffer.concat(resChunks).toString();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
captures.push({
|
|
118
|
+
request: { method: req.method!, path: url.pathname, queryParams, body },
|
|
119
|
+
response: { status: proxyRes.statusCode!, body: resBody },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
123
|
+
res.end(Buffer.concat(resChunks));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
proxyReq.on('error', (err) => {
|
|
128
|
+
console.error('Proxy request error:', err.message);
|
|
129
|
+
res.writeHead(502);
|
|
130
|
+
res.end('Proxy error');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (chunks.length > 0) proxyReq.write(Buffer.concat(chunks));
|
|
134
|
+
proxyReq.end();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
server.listen(0, () => {
|
|
139
|
+
const addr = server.address() as any;
|
|
140
|
+
resolve({ port: addr.port, close: () => server.close() });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Manifest loading
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
|
|
150
|
+
const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
|
|
151
|
+
if (!existsSync(manifestPath)) {
|
|
152
|
+
console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
|
|
153
|
+
console.warn(' Method resolution will rely on heuristic tiers — most operations may be skipped.');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
157
|
+
const manifest = new Map<string, ManifestEntry>();
|
|
158
|
+
for (const [httpKey, entry] of Object.entries(raw)) {
|
|
159
|
+
manifest.set(httpKey, entry as ManifestEntry);
|
|
160
|
+
}
|
|
161
|
+
return manifest;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Method resolution — 2 tiers: manifest, exact match
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Convert a camelCase or snake_case name to PascalCase for .NET conventions.
|
|
170
|
+
*/
|
|
171
|
+
function toPascalCase(name: string): string {
|
|
172
|
+
const camel = toCamelCase(name);
|
|
173
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveMethod(
|
|
177
|
+
op: Operation,
|
|
178
|
+
irService: string,
|
|
179
|
+
manifest: Map<string, ManifestEntry> | null,
|
|
180
|
+
): MethodResolution | null {
|
|
181
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
182
|
+
|
|
183
|
+
// Tier 0: Manifest match (primary for generated SDKs)
|
|
184
|
+
if (manifest) {
|
|
185
|
+
const entry = manifest.get(httpKey);
|
|
186
|
+
if (entry) {
|
|
187
|
+
return {
|
|
188
|
+
service: entry.service,
|
|
189
|
+
method: entry.sdkMethod,
|
|
190
|
+
tier: 'manifest',
|
|
191
|
+
confidence: 1.0,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Tier 1: Exact match — IR operation name in PascalCase + Async suffix
|
|
197
|
+
const sdkProp = SERVICE_PROPERTY_MAP[irService] || toPascalCase(irService);
|
|
198
|
+
const exactName = toPascalCase(op.name) + 'Async';
|
|
199
|
+
return {
|
|
200
|
+
service: sdkProp,
|
|
201
|
+
method: exactName,
|
|
202
|
+
tier: 'exact',
|
|
203
|
+
confidence: 0.8,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Argument construction (for .NET driver code generation)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function buildDotnetArgs(
|
|
212
|
+
op: Operation,
|
|
213
|
+
pathParams: Record<string, string>,
|
|
214
|
+
spec: any,
|
|
215
|
+
): {
|
|
216
|
+
positionalArgs: string[];
|
|
217
|
+
bodyPayload: Record<string, unknown> | null;
|
|
218
|
+
queryOpts: Record<string, unknown> | null;
|
|
219
|
+
} {
|
|
220
|
+
const positionalArgs: string[] = [];
|
|
221
|
+
let bodyPayload: Record<string, unknown> | null = null;
|
|
222
|
+
let queryOpts: Record<string, unknown> | null = null;
|
|
223
|
+
|
|
224
|
+
// Path params as positional args
|
|
225
|
+
for (const p of op.pathParams) {
|
|
226
|
+
positionalArgs.push(pathParams[p.name]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Request body (camelCase — will be converted to PascalCase in C# generation)
|
|
230
|
+
if (op.requestBody) {
|
|
231
|
+
bodyPayload = generateCamelPayload(op, spec);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Query params (camelCase — will be converted to PascalCase in C# generation)
|
|
235
|
+
if (!op.requestBody && op.queryParams.some((p) => p.required)) {
|
|
236
|
+
const params = generateCamelQueryParams(op, spec);
|
|
237
|
+
if (Object.keys(params).length > 0) {
|
|
238
|
+
queryOpts = params;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Pagination
|
|
243
|
+
if (op.pagination) {
|
|
244
|
+
if (!queryOpts) queryOpts = {};
|
|
245
|
+
queryOpts['limit'] = 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { positionalArgs, bodyPayload, queryOpts };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// C# value serialization for code generation
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function toCSharpValue(value: unknown, indent: number = 12): string {
|
|
256
|
+
if (value === null || value === undefined) return 'null';
|
|
257
|
+
if (typeof value === 'string') return `"${value}"`;
|
|
258
|
+
if (typeof value === 'number') return Number.isInteger(value) ? `${value}` : `${value}m`;
|
|
259
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
260
|
+
if (Array.isArray(value)) {
|
|
261
|
+
const pad = ' '.repeat(indent);
|
|
262
|
+
const items = value.map((v) => `${pad} ${toCSharpValue(v, indent + 4)}`);
|
|
263
|
+
return `new List<object>\n${pad}{\n${items.join(',\n')}\n${pad}}`;
|
|
264
|
+
}
|
|
265
|
+
if (typeof value === 'object') {
|
|
266
|
+
const pad = ' '.repeat(indent);
|
|
267
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
268
|
+
.map(([k, v]) => `${pad} { "${k}", ${toCSharpValue(v, indent + 4)} }`)
|
|
269
|
+
.join(',\n');
|
|
270
|
+
return `new Dictionary<string, object>\n${pad}{\n${entries}\n${pad}}`;
|
|
271
|
+
}
|
|
272
|
+
return `${value}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function toCSharpObjectInitializer(obj: Record<string, unknown>, indent: number = 12): string {
|
|
276
|
+
return toCSharpValue(obj, indent);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Batched C# source generation
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
interface PlannedCall {
|
|
284
|
+
index: number;
|
|
285
|
+
op: Operation;
|
|
286
|
+
irService: string;
|
|
287
|
+
resolution: MethodResolution;
|
|
288
|
+
pathParams: Record<string, string>;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build a single Program.cs that calls ALL planned operations sequentially.
|
|
293
|
+
* Each call is wrapped with stderr markers for correlation with proxy captures.
|
|
294
|
+
*/
|
|
295
|
+
function buildBatchedCSharpScript(port: number, ns: string, calls: PlannedCall[], spec: any): string {
|
|
296
|
+
const lines: string[] = [];
|
|
297
|
+
|
|
298
|
+
// Preamble — loaded once
|
|
299
|
+
lines.push('using System;');
|
|
300
|
+
lines.push('using System.Net.Http;');
|
|
301
|
+
lines.push('using System.Net.Http.Headers;');
|
|
302
|
+
lines.push('using System.Text;');
|
|
303
|
+
lines.push('using System.Threading.Tasks;');
|
|
304
|
+
lines.push('using Newtonsoft.Json;');
|
|
305
|
+
lines.push(`using ${ns};`);
|
|
306
|
+
lines.push('');
|
|
307
|
+
|
|
308
|
+
// Shared HttpClient for body operations
|
|
309
|
+
lines.push('var httpClient = new HttpClient();');
|
|
310
|
+
lines.push(`httpClient.BaseAddress = new Uri("http://localhost:${port}");`);
|
|
311
|
+
lines.push('httpClient.Timeout = TimeSpan.FromSeconds(30);');
|
|
312
|
+
lines.push('httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "api_key");');
|
|
313
|
+
lines.push('httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));');
|
|
314
|
+
lines.push('httpClient.DefaultRequestHeaders.ExpectContinue = false;');
|
|
315
|
+
lines.push('');
|
|
316
|
+
|
|
317
|
+
// Shared SDK client for GET/DELETE operations
|
|
318
|
+
lines.push(`var client = new ${ns}Client(apiKey: "api_key", baseUrl: "http://localhost:${port}");`);
|
|
319
|
+
lines.push('');
|
|
320
|
+
|
|
321
|
+
for (const call of calls) {
|
|
322
|
+
const { index, op, resolution, pathParams } = call;
|
|
323
|
+
const { positionalArgs, bodyPayload, queryOpts } = buildDotnetArgs(op, pathParams, spec);
|
|
324
|
+
|
|
325
|
+
// Marker: start
|
|
326
|
+
lines.push(`Console.Error.WriteLine("OAGEN_CALL_START:${index}");`);
|
|
327
|
+
lines.push('Console.Error.Flush();');
|
|
328
|
+
|
|
329
|
+
lines.push('try');
|
|
330
|
+
lines.push('{');
|
|
331
|
+
|
|
332
|
+
if (bodyPayload) {
|
|
333
|
+
// For body operations (POST/PUT/PATCH), use HttpClient directly
|
|
334
|
+
const payloadJson = JSON.stringify(bodyPayload).replace(/\\/g, '\\\\').replace(/"/g, '""');
|
|
335
|
+
|
|
336
|
+
let urlPath = op.path;
|
|
337
|
+
for (let i = 0; i < op.pathParams.length; i++) {
|
|
338
|
+
urlPath = urlPath.replace(`{${op.pathParams[i].name}}`, positionalArgs[i] ?? 'test_id');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const httpMethod = op.httpMethod.toUpperCase();
|
|
342
|
+
|
|
343
|
+
lines.push(` var payloadJson_${index} = @"${payloadJson}";`);
|
|
344
|
+
lines.push(
|
|
345
|
+
` var content_${index} = new StringContent(payloadJson_${index}, Encoding.UTF8, "application/json");`,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (httpMethod === 'POST') {
|
|
349
|
+
lines.push(` var response_${index} = await httpClient.PostAsync("${urlPath}", content_${index});`);
|
|
350
|
+
} else if (httpMethod === 'PUT') {
|
|
351
|
+
lines.push(` var response_${index} = await httpClient.PutAsync("${urlPath}", content_${index});`);
|
|
352
|
+
} else if (httpMethod === 'PATCH') {
|
|
353
|
+
lines.push(
|
|
354
|
+
` var request_${index} = new HttpRequestMessage(new HttpMethod("PATCH"), "${urlPath}") { Content = content_${index} };`,
|
|
355
|
+
);
|
|
356
|
+
lines.push(` var response_${index} = await httpClient.SendAsync(request_${index});`);
|
|
357
|
+
} else {
|
|
358
|
+
lines.push(` var response_${index} = await httpClient.PostAsync("${urlPath}", content_${index});`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
lines.push(` var responseBody_${index} = await response_${index}.Content.ReadAsStringAsync();`);
|
|
362
|
+
lines.push(` Console.WriteLine(responseBody_${index});`);
|
|
363
|
+
} else {
|
|
364
|
+
// For GET/DELETE/list operations, call the SDK directly
|
|
365
|
+
const argsStr = positionalArgs.map((a) => `"${a}"`).join(', ');
|
|
366
|
+
let callParts: string[] = [];
|
|
367
|
+
if (argsStr) {
|
|
368
|
+
callParts.push(argsStr);
|
|
369
|
+
}
|
|
370
|
+
if (queryOpts) {
|
|
371
|
+
callParts.push(toCSharpObjectInitializer(queryOpts));
|
|
372
|
+
}
|
|
373
|
+
const callArgsStr = callParts.join(', ');
|
|
374
|
+
|
|
375
|
+
lines.push(` var result_${index} = await client.${resolution.service}.${resolution.method}(${callArgsStr});`);
|
|
376
|
+
lines.push(` Console.WriteLine(JsonConvert.SerializeObject(result_${index}, Formatting.Indented));`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
lines.push(` Console.Error.WriteLine("OAGEN_CALL_OK:${index}");`);
|
|
380
|
+
lines.push('}');
|
|
381
|
+
lines.push('catch (Exception ex)');
|
|
382
|
+
lines.push('{');
|
|
383
|
+
lines.push(` Console.Error.WriteLine($"OAGEN_CALL_ERROR:${index}:{{ex.GetType().Name}}: {{ex.Message}}");`);
|
|
384
|
+
lines.push('}');
|
|
385
|
+
|
|
386
|
+
// Marker: end
|
|
387
|
+
lines.push(`Console.Error.WriteLine("OAGEN_CALL_END:${index}");`);
|
|
388
|
+
lines.push('Console.Error.Flush();');
|
|
389
|
+
|
|
390
|
+
// Small delay to let proxy settle
|
|
391
|
+
lines.push('await Task.Delay(50);');
|
|
392
|
+
lines.push('');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return lines.join('\n') + '\n';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// .NET project discovery
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Find the .csproj file in the SDK directory. Returns the full resolved path.
|
|
404
|
+
*/
|
|
405
|
+
function findCsproj(sdkPath: string): string {
|
|
406
|
+
const files = readdirSync(sdkPath).filter((f) => f.endsWith('.csproj'));
|
|
407
|
+
if (files.length === 0) {
|
|
408
|
+
throw new Error(`No .csproj file found in ${sdkPath}`);
|
|
409
|
+
}
|
|
410
|
+
return resolve(sdkPath, files[0]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Detect the root namespace from the .csproj file's RootNamespace property.
|
|
415
|
+
* Falls back to the csproj filename (without extension) if not found.
|
|
416
|
+
*/
|
|
417
|
+
function detectNamespace(sdkPath: string): string {
|
|
418
|
+
const csprojPath = findCsproj(sdkPath);
|
|
419
|
+
const content = readFileSync(csprojPath, 'utf-8');
|
|
420
|
+
const match = content.match(/<RootNamespace>([^<]+)<\/RootNamespace>/);
|
|
421
|
+
if (match) return match[1];
|
|
422
|
+
// Fallback: use .csproj filename without extension
|
|
423
|
+
const base = csprojPath.split('/').pop() ?? '';
|
|
424
|
+
return base.replace('.csproj', '');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// .NET project generation
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
function writeDotnetProject(tmpDir: string, _sdkPath: string, programCs: string): void {
|
|
432
|
+
writeFileSync(join(tmpDir, 'Program.cs'), programCs);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Spawn-based wave execution
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Run `dotnet run` for a wave via `spawn` so stderr markers can be parsed
|
|
441
|
+
* in real-time and the proxy event loop stays responsive.
|
|
442
|
+
*/
|
|
443
|
+
function runDotnetWave(
|
|
444
|
+
tmpDir: string,
|
|
445
|
+
apiKey: string,
|
|
446
|
+
proxyPort: number,
|
|
447
|
+
captures: ProxyCapture[],
|
|
448
|
+
): Promise<
|
|
449
|
+
Map<
|
|
450
|
+
number,
|
|
451
|
+
{
|
|
452
|
+
captureIndexBefore: number;
|
|
453
|
+
captureIndexAfter: number;
|
|
454
|
+
error?: string;
|
|
455
|
+
startTime: number;
|
|
456
|
+
endTime: number;
|
|
457
|
+
}
|
|
458
|
+
>
|
|
459
|
+
> {
|
|
460
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
461
|
+
const callResults = new Map<
|
|
462
|
+
number,
|
|
463
|
+
{
|
|
464
|
+
captureIndexBefore: number;
|
|
465
|
+
captureIndexAfter: number;
|
|
466
|
+
error?: string;
|
|
467
|
+
startTime: number;
|
|
468
|
+
endTime: number;
|
|
469
|
+
}
|
|
470
|
+
>();
|
|
471
|
+
|
|
472
|
+
let currentCallIndex = -1;
|
|
473
|
+
let currentCallStart = Date.now();
|
|
474
|
+
let currentCapturesBefore = 0;
|
|
475
|
+
|
|
476
|
+
const child = spawn('dotnet', ['run', '--no-restore'], {
|
|
477
|
+
cwd: tmpDir,
|
|
478
|
+
env: {
|
|
479
|
+
...process.env,
|
|
480
|
+
WORKOS_API_KEY: apiKey,
|
|
481
|
+
WORKOS_BASE_URL: `http://localhost:${proxyPort}`,
|
|
482
|
+
DOTNET_NOLOGO: '1',
|
|
483
|
+
},
|
|
484
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const timeout = setTimeout(() => {
|
|
488
|
+
child.kill('SIGKILL');
|
|
489
|
+
rejectPromise(new Error('Batch dotnet script timed out after 300s'));
|
|
490
|
+
}, 300_000);
|
|
491
|
+
|
|
492
|
+
let stderrBuf = '';
|
|
493
|
+
|
|
494
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
495
|
+
stderrBuf += data.toString();
|
|
496
|
+
const lines = stderrBuf.split('\n');
|
|
497
|
+
stderrBuf = lines.pop() || '';
|
|
498
|
+
|
|
499
|
+
for (const line of lines) {
|
|
500
|
+
const trimmed = line.trim();
|
|
501
|
+
if (trimmed.startsWith('OAGEN_CALL_START:')) {
|
|
502
|
+
const idx = parseInt(trimmed.slice('OAGEN_CALL_START:'.length), 10);
|
|
503
|
+
currentCallIndex = idx;
|
|
504
|
+
currentCallStart = Date.now();
|
|
505
|
+
currentCapturesBefore = captures.length;
|
|
506
|
+
} else if (trimmed.startsWith('OAGEN_CALL_OK:')) {
|
|
507
|
+
const idx = parseInt(trimmed.slice('OAGEN_CALL_OK:'.length), 10);
|
|
508
|
+
if (callResults.has(idx)) continue;
|
|
509
|
+
callResults.set(idx, {
|
|
510
|
+
captureIndexBefore: currentCapturesBefore,
|
|
511
|
+
captureIndexAfter: captures.length,
|
|
512
|
+
startTime: currentCallStart,
|
|
513
|
+
endTime: Date.now(),
|
|
514
|
+
});
|
|
515
|
+
} else if (trimmed.startsWith('OAGEN_CALL_ERROR:')) {
|
|
516
|
+
const rest = trimmed.slice('OAGEN_CALL_ERROR:'.length);
|
|
517
|
+
const colonIdx = rest.indexOf(':');
|
|
518
|
+
const idx = parseInt(rest.slice(0, colonIdx), 10);
|
|
519
|
+
const errMsg = rest.slice(colonIdx + 1);
|
|
520
|
+
if (callResults.has(idx)) continue;
|
|
521
|
+
callResults.set(idx, {
|
|
522
|
+
captureIndexBefore: currentCapturesBefore,
|
|
523
|
+
captureIndexAfter: captures.length,
|
|
524
|
+
error: errMsg,
|
|
525
|
+
startTime: currentCallStart,
|
|
526
|
+
endTime: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
} else if (trimmed.startsWith('OAGEN_CALL_END:')) {
|
|
529
|
+
const idx = parseInt(trimmed.slice('OAGEN_CALL_END:'.length), 10);
|
|
530
|
+
const existing = callResults.get(idx);
|
|
531
|
+
if (existing) {
|
|
532
|
+
existing.captureIndexAfter = captures.length;
|
|
533
|
+
existing.endTime = Date.now();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
child.on('close', () => {
|
|
540
|
+
clearTimeout(timeout);
|
|
541
|
+
// Process any remaining stderr buffer
|
|
542
|
+
if (stderrBuf.trim()) {
|
|
543
|
+
const trimmed = stderrBuf.trim();
|
|
544
|
+
if (trimmed.startsWith('OAGEN_CALL_END:') && currentCallIndex >= 0) {
|
|
545
|
+
const existing = callResults.get(currentCallIndex);
|
|
546
|
+
if (existing) {
|
|
547
|
+
existing.captureIndexAfter = captures.length;
|
|
548
|
+
existing.endTime = Date.now();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
resolvePromise(callResults);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
child.on('error', (err) => {
|
|
556
|
+
clearTimeout(timeout);
|
|
557
|
+
rejectPromise(err);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Main
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
async function main(): Promise<void> {
|
|
567
|
+
const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
|
|
568
|
+
|
|
569
|
+
if (!sdkPath) {
|
|
570
|
+
console.error('--sdk-path is required');
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
|
|
575
|
+
if (!apiKey) {
|
|
576
|
+
console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Load config
|
|
581
|
+
loadSmokeConfig(smokeConfig);
|
|
582
|
+
|
|
583
|
+
// Parse spec
|
|
584
|
+
console.log('Parsing spec...');
|
|
585
|
+
const spec = await parseSpec(specPath);
|
|
586
|
+
console.log(`Spec: ${spec.name} v${spec.version}`);
|
|
587
|
+
|
|
588
|
+
// Detect SDK namespace
|
|
589
|
+
const ns = detectNamespace(sdkPath);
|
|
590
|
+
console.log(`SDK namespace: ${ns}`);
|
|
591
|
+
|
|
592
|
+
// Load manifest
|
|
593
|
+
const manifest = loadManifest(sdkPath);
|
|
594
|
+
|
|
595
|
+
// Start proxy
|
|
596
|
+
const captures: ProxyCapture[] = [];
|
|
597
|
+
const proxy = await createProxyServer(apiKey, captures);
|
|
598
|
+
console.log(`Proxy listening on port ${proxy.port}`);
|
|
599
|
+
|
|
600
|
+
// Plan operations
|
|
601
|
+
const groups = planOperations(spec);
|
|
602
|
+
const ids = new IdRegistry();
|
|
603
|
+
const exchanges: CapturedExchange[] = [];
|
|
604
|
+
|
|
605
|
+
let successCount = 0;
|
|
606
|
+
let errorCount = 0;
|
|
607
|
+
let skipCount = 0;
|
|
608
|
+
let unexpectedCount = 0;
|
|
609
|
+
|
|
610
|
+
// Create temp directory for .NET driver (clean any stale state)
|
|
611
|
+
const tmpDir = resolve(sdkPath, '.smoke-tmp-dotnet');
|
|
612
|
+
if (existsSync(tmpDir)) {
|
|
613
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Step 1: Build the SDK project to a DLL
|
|
617
|
+
const sdkCsprojPath = findCsproj(sdkPath);
|
|
618
|
+
console.log('Building SDK...');
|
|
619
|
+
try {
|
|
620
|
+
execSync(`dotnet build "${sdkCsprojPath}" -c Release -o "${resolve(sdkPath, 'bin/Release/net8.0')}"`, {
|
|
621
|
+
cwd: sdkPath,
|
|
622
|
+
timeout: 120000,
|
|
623
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
624
|
+
env: { ...process.env, DOTNET_NOLOGO: '1' },
|
|
625
|
+
});
|
|
626
|
+
console.log('SDK built successfully');
|
|
627
|
+
} catch (err) {
|
|
628
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
629
|
+
console.error(`Failed to build SDK: ${msg}`);
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Find the SDK DLL
|
|
634
|
+
const sdkDllDir = resolve(sdkPath, 'bin/Release/net8.0');
|
|
635
|
+
const sdkDll = resolve(sdkDllDir, `${ns}.dll`);
|
|
636
|
+
|
|
637
|
+
// Step 2: Bootstrap the driver project referencing the built DLL
|
|
638
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
639
|
+
const csprojPath = join(tmpDir, 'SmokeDriver.csproj');
|
|
640
|
+
const csprojContent = `<Project Sdk="Microsoft.NET.Sdk">
|
|
641
|
+
<PropertyGroup>
|
|
642
|
+
<OutputType>Exe</OutputType>
|
|
643
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
644
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
645
|
+
<Nullable>enable</Nullable>
|
|
646
|
+
</PropertyGroup>
|
|
647
|
+
<ItemGroup>
|
|
648
|
+
<Reference Include="${ns}">
|
|
649
|
+
<HintPath>${sdkDll}</HintPath>
|
|
650
|
+
</Reference>
|
|
651
|
+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
|
652
|
+
</ItemGroup>
|
|
653
|
+
</Project>
|
|
654
|
+
`;
|
|
655
|
+
writeFileSync(csprojPath, csprojContent);
|
|
656
|
+
writeFileSync(join(tmpDir, 'Program.cs'), 'Console.WriteLine("bootstrap");');
|
|
657
|
+
|
|
658
|
+
// Build the driver once to warm up
|
|
659
|
+
try {
|
|
660
|
+
execSync('dotnet build', {
|
|
661
|
+
cwd: tmpDir,
|
|
662
|
+
timeout: 120000,
|
|
663
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
664
|
+
env: { ...process.env, DOTNET_NOLOGO: '1' },
|
|
665
|
+
});
|
|
666
|
+
console.log('Driver project bootstrapped');
|
|
667
|
+
} catch (err) {
|
|
668
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
669
|
+
console.error(`Failed to build driver: ${msg}`);
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Use wave-based planning: execute parameterless ops first, extract IDs,
|
|
674
|
+
// then plan the next wave of ops whose path params are now resolvable.
|
|
675
|
+
let globalCallIndex = 0;
|
|
676
|
+
|
|
677
|
+
const waveIterator = planWaves(groups, ids, (op, irService) => {
|
|
678
|
+
const resolution = resolveMethod(op, irService, manifest);
|
|
679
|
+
return resolution !== null;
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
let waveNumber = 0;
|
|
683
|
+
let waveResult = waveIterator.next();
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
while (!waveResult.done) {
|
|
687
|
+
const wave: OperationWave = waveResult.value;
|
|
688
|
+
waveNumber++;
|
|
689
|
+
|
|
690
|
+
// Build planned calls for this wave, resolving methods
|
|
691
|
+
const plannedCalls: PlannedCall[] = [];
|
|
692
|
+
const waveSkipped: Array<{ op: Operation; irService: string; reason: string }> = [];
|
|
693
|
+
|
|
694
|
+
for (const { op, irService, pathParams } of wave.calls) {
|
|
695
|
+
const resolution = resolveMethod(op, irService, manifest);
|
|
696
|
+
if (!resolution) {
|
|
697
|
+
waveSkipped.push({ op, irService, reason: 'No matching SDK method' });
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
plannedCalls.push({
|
|
701
|
+
index: globalCallIndex++,
|
|
702
|
+
op,
|
|
703
|
+
irService,
|
|
704
|
+
resolution,
|
|
705
|
+
pathParams,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Record skipped exchanges for this wave
|
|
710
|
+
for (const skip of waveSkipped) {
|
|
711
|
+
exchanges.push(makeSkippedExchange(skip.op, skip.irService, skip.reason));
|
|
712
|
+
skipCount++;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (plannedCalls.length === 0) {
|
|
716
|
+
waveResult = waveIterator.next();
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
console.log(`\n=== Wave ${waveNumber} (${plannedCalls.length} operations) ===`);
|
|
721
|
+
|
|
722
|
+
// Generate batched C# script for this wave
|
|
723
|
+
const programCs = buildBatchedCSharpScript(proxy.port, ns, plannedCalls, spec);
|
|
724
|
+
|
|
725
|
+
writeDotnetProject(tmpDir, sdkPath, programCs);
|
|
726
|
+
|
|
727
|
+
// Execute the batched script via spawn
|
|
728
|
+
let callResults: Map<
|
|
729
|
+
number,
|
|
730
|
+
{
|
|
731
|
+
captureIndexBefore: number;
|
|
732
|
+
captureIndexAfter: number;
|
|
733
|
+
error?: string;
|
|
734
|
+
startTime: number;
|
|
735
|
+
endTime: number;
|
|
736
|
+
}
|
|
737
|
+
>;
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
callResults = await runDotnetWave(tmpDir, apiKey, proxy.port, captures);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
743
|
+
console.error(`Batch execution error: ${message}`);
|
|
744
|
+
callResults = new Map();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
await delay(200);
|
|
748
|
+
|
|
749
|
+
// Process results for this wave — extract IDs so the next wave can use them
|
|
750
|
+
for (const call of plannedCalls) {
|
|
751
|
+
const { index, op, irService, resolution } = call;
|
|
752
|
+
const isTopLevel = op.pathParams.length === 0;
|
|
753
|
+
const result = callResults.get(index);
|
|
754
|
+
|
|
755
|
+
if (!result) {
|
|
756
|
+
exchanges.push({
|
|
757
|
+
...makeSkippedExchange(op, irService, 'Call did not execute (batch script may have failed)'),
|
|
758
|
+
outcome: 'api-error',
|
|
759
|
+
durationMs: 0,
|
|
760
|
+
});
|
|
761
|
+
errorCount++;
|
|
762
|
+
console.log(` X ${op.name} -- did not execute`);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const elapsed = result.endTime - result.startTime;
|
|
767
|
+
|
|
768
|
+
if (result.captureIndexAfter <= result.captureIndexBefore) {
|
|
769
|
+
if (result.error) {
|
|
770
|
+
exchanges.push({
|
|
771
|
+
...makeSkippedExchange(op, irService, result.error),
|
|
772
|
+
outcome: 'api-error',
|
|
773
|
+
durationMs: elapsed,
|
|
774
|
+
});
|
|
775
|
+
errorCount++;
|
|
776
|
+
console.log(` X ${op.name} -- ${result.error.split('\n')[0]}`);
|
|
777
|
+
} else {
|
|
778
|
+
exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
|
|
779
|
+
skipCount++;
|
|
780
|
+
console.log(` SKIP ${op.name} -- no HTTP capture`);
|
|
781
|
+
}
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const capture = captures[result.captureIndexAfter - 1];
|
|
786
|
+
const exchange = buildExchange(op, irService, capture, elapsed, resolution);
|
|
787
|
+
|
|
788
|
+
if (result.error) {
|
|
789
|
+
exchange.error = result.error;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Extract IDs from response (critical: feeds the next wave)
|
|
793
|
+
ids.extractAndStore(irService, capture.response.body, isTopLevel);
|
|
794
|
+
|
|
795
|
+
if (exchange.unexpectedStatus) {
|
|
796
|
+
unexpectedCount++;
|
|
797
|
+
console.log(` ! ${op.name} -> ${capture.response.status} (unexpected)`);
|
|
798
|
+
} else if (exchange.outcome === 'api-error') {
|
|
799
|
+
errorCount++;
|
|
800
|
+
console.log(` X ${op.name} -> ${capture.response.status}`);
|
|
801
|
+
} else {
|
|
802
|
+
successCount++;
|
|
803
|
+
console.log(` OK ${op.name} -> ${capture.response.status} (${elapsed}ms)`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
exchanges.push(exchange);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Advance to the next wave (IDs from this wave are now in the registry)
|
|
810
|
+
waveResult = waveIterator.next();
|
|
811
|
+
}
|
|
812
|
+
} finally {
|
|
813
|
+
// Cleanup temp directory
|
|
814
|
+
if (existsSync(tmpDir)) {
|
|
815
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
816
|
+
}
|
|
817
|
+
proxy.close();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Record any operations that could never be resolved
|
|
821
|
+
if (waveResult.done && waveResult.value) {
|
|
822
|
+
for (const unresolved of waveResult.value) {
|
|
823
|
+
exchanges.push(makeSkippedExchange(unresolved.operation, unresolved.service, 'Missing path param IDs'));
|
|
824
|
+
skipCount++;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Write results
|
|
829
|
+
const results: SmokeResults = {
|
|
830
|
+
source: 'sdk-dotnet',
|
|
831
|
+
timestamp: new Date().toISOString(),
|
|
832
|
+
specVersion: spec.version,
|
|
833
|
+
exchanges,
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const outputPath = 'smoke-results-sdk-dotnet.json';
|
|
837
|
+
writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
838
|
+
console.log(`\nResults written to ${outputPath}`);
|
|
839
|
+
|
|
840
|
+
// Summary
|
|
841
|
+
const total = exchanges.length;
|
|
842
|
+
console.log(`\n=== Summary ===`);
|
|
843
|
+
console.log(` Total: ${total}`);
|
|
844
|
+
console.log(` Success: ${successCount}`);
|
|
845
|
+
console.log(` API errors: ${errorCount}`);
|
|
846
|
+
console.log(` Skipped: ${skipCount}`);
|
|
847
|
+
if (unexpectedCount > 0) {
|
|
848
|
+
console.log(` Unexpected: ${unexpectedCount}`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// Helpers
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
|
|
857
|
+
return {
|
|
858
|
+
operationId: op.name,
|
|
859
|
+
service,
|
|
860
|
+
operationName: op.name,
|
|
861
|
+
request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
|
|
862
|
+
response: { status: 0, body: null },
|
|
863
|
+
outcome: 'skipped',
|
|
864
|
+
error: reason,
|
|
865
|
+
durationMs: 0,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function buildExchange(
|
|
870
|
+
op: Operation,
|
|
871
|
+
service: string,
|
|
872
|
+
capture: ProxyCapture,
|
|
873
|
+
durationMs: number,
|
|
874
|
+
resolution: MethodResolution,
|
|
875
|
+
): CapturedExchange {
|
|
876
|
+
const status = capture.response.status;
|
|
877
|
+
const expectedCodes = getExpectedStatusCodes(op);
|
|
878
|
+
const unexpected = isUnexpectedStatus(status, op);
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
operationId: op.name,
|
|
882
|
+
service,
|
|
883
|
+
operationName: op.name,
|
|
884
|
+
request: capture.request,
|
|
885
|
+
response: capture.response,
|
|
886
|
+
outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
|
|
887
|
+
unexpectedStatus: unexpected || undefined,
|
|
888
|
+
expectedStatusCodes: expectedCodes,
|
|
889
|
+
durationMs,
|
|
890
|
+
provenance: {
|
|
891
|
+
resolutionTier: resolution.tier,
|
|
892
|
+
resolutionConfidence: resolution.confidence,
|
|
893
|
+
sdkMethodName: `${resolution.service}.${resolution.method}`,
|
|
894
|
+
captureIndex: 0,
|
|
895
|
+
totalCaptures: 1,
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
main().catch((err) => {
|
|
901
|
+
console.error('Fatal error:', err);
|
|
902
|
+
process.exit(1);
|
|
903
|
+
});
|