@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
package/smoke/sdk-go.ts
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go SDK smoke test -- captures wire-level HTTP exchanges from the generated
|
|
3
|
+
* Go SDK by running a local HTTP proxy, generating a Go test program that
|
|
4
|
+
* calls the SDK through it, and collecting the captured request/response pairs.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx tsx smoke/sdk-go.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 } from 'node:path';
|
|
14
|
+
import { execSync, spawn } from 'node:child_process';
|
|
15
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
16
|
+
import {
|
|
17
|
+
parseSpec,
|
|
18
|
+
planOperations,
|
|
19
|
+
planWaves,
|
|
20
|
+
generatePayload,
|
|
21
|
+
IdRegistry,
|
|
22
|
+
delay,
|
|
23
|
+
parseCliArgs,
|
|
24
|
+
loadSmokeConfig,
|
|
25
|
+
getExpectedStatusCodes,
|
|
26
|
+
isUnexpectedStatus,
|
|
27
|
+
toSnakeCase,
|
|
28
|
+
toCamelCase,
|
|
29
|
+
SERVICE_PROPERTY_MAP,
|
|
30
|
+
} from '@workos/oagen/smoke';
|
|
31
|
+
import type { CapturedExchange, SmokeResults, ExchangeProvenance, OperationWave } from '@workos/oagen/smoke';
|
|
32
|
+
import type { Operation } from '@workos/oagen';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
interface ManifestEntry {
|
|
39
|
+
sdkMethod: string;
|
|
40
|
+
service: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CapturedRequest {
|
|
44
|
+
method: string;
|
|
45
|
+
path: string;
|
|
46
|
+
queryParams: Record<string, string>;
|
|
47
|
+
body: unknown | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CapturedResponse {
|
|
51
|
+
status: number;
|
|
52
|
+
body: unknown | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ProxyExchange {
|
|
56
|
+
request: CapturedRequest;
|
|
57
|
+
response: CapturedResponse;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Go naming conventions (mirror the emitter's naming.ts)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const GO_ACRONYMS = new Set([
|
|
65
|
+
'ID',
|
|
66
|
+
'URL',
|
|
67
|
+
'API',
|
|
68
|
+
'HTTP',
|
|
69
|
+
'HTTPS',
|
|
70
|
+
'JSON',
|
|
71
|
+
'XML',
|
|
72
|
+
'SQL',
|
|
73
|
+
'HTML',
|
|
74
|
+
'CSS',
|
|
75
|
+
'URI',
|
|
76
|
+
'SSO',
|
|
77
|
+
'IP',
|
|
78
|
+
'TLS',
|
|
79
|
+
'SSL',
|
|
80
|
+
'DNS',
|
|
81
|
+
'TCP',
|
|
82
|
+
'UDP',
|
|
83
|
+
'SSH',
|
|
84
|
+
'JWT',
|
|
85
|
+
'OAuth',
|
|
86
|
+
'SDK',
|
|
87
|
+
'CLI',
|
|
88
|
+
'MFA',
|
|
89
|
+
'SAML',
|
|
90
|
+
'SCIM',
|
|
91
|
+
'DSYNC',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
function goAcronyms(name: string): string {
|
|
95
|
+
return name.replace(/[A-Z][a-z]*/g, (segment) => {
|
|
96
|
+
const upper = segment.toUpperCase();
|
|
97
|
+
if (GO_ACRONYMS.has(upper)) return upper;
|
|
98
|
+
return segment;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toPascalCase(s: string): string {
|
|
103
|
+
return s
|
|
104
|
+
.replace(/[-_]+/g, ' ')
|
|
105
|
+
.replace(/\s+/g, ' ')
|
|
106
|
+
.trim()
|
|
107
|
+
.split(' ')
|
|
108
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
109
|
+
.join('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function goExportedName(name: string): string {
|
|
113
|
+
return goAcronyms(toPascalCase(name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function goServicePackageName(name: string): string {
|
|
117
|
+
return toSnakeCase(name).replace(/_/g, '').toLowerCase();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function goFieldName(name: string): string {
|
|
121
|
+
return goAcronyms(toPascalCase(name));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Manifest loading
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
|
|
129
|
+
const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
|
|
130
|
+
if (!existsSync(manifestPath)) {
|
|
131
|
+
console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
|
|
132
|
+
console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
136
|
+
const manifest = new Map<string, ManifestEntry>();
|
|
137
|
+
for (const [httpKey, entry] of Object.entries(raw)) {
|
|
138
|
+
manifest.set(httpKey, entry as ManifestEntry);
|
|
139
|
+
}
|
|
140
|
+
return manifest;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Method resolution
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
interface MethodResolution {
|
|
148
|
+
service: string;
|
|
149
|
+
method: string;
|
|
150
|
+
tier: ExchangeProvenance['resolutionTier'];
|
|
151
|
+
confidence: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveMethod(
|
|
155
|
+
op: Operation,
|
|
156
|
+
irService: string,
|
|
157
|
+
manifest: Map<string, ManifestEntry> | null,
|
|
158
|
+
): MethodResolution | null {
|
|
159
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
160
|
+
|
|
161
|
+
// Tier 0: Manifest match (primary for generated SDKs)
|
|
162
|
+
if (manifest) {
|
|
163
|
+
const entry = manifest.get(httpKey);
|
|
164
|
+
if (entry) {
|
|
165
|
+
return {
|
|
166
|
+
service: entry.service,
|
|
167
|
+
method: entry.sdkMethod,
|
|
168
|
+
tier: 'manifest',
|
|
169
|
+
confidence: 1.0,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Tier 1: Exact match -- IR operation name in PascalCase (Go convention)
|
|
175
|
+
const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
|
|
176
|
+
const exactName = goExportedName(op.name);
|
|
177
|
+
return {
|
|
178
|
+
service: sdkProp,
|
|
179
|
+
method: exactName,
|
|
180
|
+
tier: 'exact',
|
|
181
|
+
confidence: 0.8,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Proxy server -- captures HTTP exchanges between Go process and real API
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
class CaptureProxy {
|
|
190
|
+
private exchanges: ProxyExchange[] = [];
|
|
191
|
+
private server: ReturnType<typeof createServer> | null = null;
|
|
192
|
+
private port = 0;
|
|
193
|
+
private targetBaseUrl: string;
|
|
194
|
+
|
|
195
|
+
constructor(targetBaseUrl: string) {
|
|
196
|
+
this.targetBaseUrl = targetBaseUrl.replace(/\/$/, '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async start(): Promise<number> {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
202
|
+
await this.handleRequest(req, res);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
this.server.listen(0, '127.0.0.1', () => {
|
|
206
|
+
const addr = this.server!.address();
|
|
207
|
+
if (addr && typeof addr === 'object') {
|
|
208
|
+
this.port = addr.port;
|
|
209
|
+
resolve(this.port);
|
|
210
|
+
} else {
|
|
211
|
+
reject(new Error('Failed to get proxy server address'));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
this.server.on('error', reject);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async stop(): Promise<void> {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
if (this.server) {
|
|
222
|
+
this.server.close(() => resolve());
|
|
223
|
+
} else {
|
|
224
|
+
resolve();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
getExchanges(): ProxyExchange[] {
|
|
230
|
+
return [...this.exchanges];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
clearExchanges(): void {
|
|
234
|
+
this.exchanges = [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
getLastExchange(): ProxyExchange | null {
|
|
238
|
+
return this.exchanges.length > 0 ? this.exchanges[this.exchanges.length - 1] : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
242
|
+
const reqBody = await this.readBody(req);
|
|
243
|
+
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`);
|
|
244
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
245
|
+
const path = url.pathname;
|
|
246
|
+
const queryParams: Record<string, string> = {};
|
|
247
|
+
url.searchParams.forEach((v, k) => {
|
|
248
|
+
queryParams[k] = v;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
let parsedReqBody: unknown = null;
|
|
252
|
+
if (reqBody) {
|
|
253
|
+
try {
|
|
254
|
+
parsedReqBody = JSON.parse(reqBody);
|
|
255
|
+
} catch {
|
|
256
|
+
parsedReqBody = reqBody;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const capturedReq: CapturedRequest = { method, path, queryParams, body: parsedReqBody };
|
|
261
|
+
|
|
262
|
+
// Forward to the real API
|
|
263
|
+
const targetUrl = new URL(path, this.targetBaseUrl);
|
|
264
|
+
url.searchParams.forEach((v, k) => {
|
|
265
|
+
targetUrl.searchParams.set(k, v);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const forwardHeaders: Record<string, string> = {};
|
|
269
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
270
|
+
if (key === 'host' || key === 'connection') continue;
|
|
271
|
+
if (value) {
|
|
272
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const fetchInit: RequestInit = {
|
|
278
|
+
method,
|
|
279
|
+
headers: forwardHeaders,
|
|
280
|
+
};
|
|
281
|
+
if (reqBody && method !== 'GET' && method !== 'HEAD') {
|
|
282
|
+
fetchInit.body = reqBody;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const response = await fetch(targetUrl.toString(), fetchInit);
|
|
286
|
+
|
|
287
|
+
let responseBody: unknown = null;
|
|
288
|
+
const responseText = await response.text();
|
|
289
|
+
try {
|
|
290
|
+
responseBody = JSON.parse(responseText);
|
|
291
|
+
} catch {
|
|
292
|
+
responseBody = responseText || null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Store the captured exchange
|
|
296
|
+
this.exchanges.push({
|
|
297
|
+
request: capturedReq,
|
|
298
|
+
response: { status: response.status, body: responseBody },
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Forward response back to Go process
|
|
302
|
+
const respHeaders: Record<string, string> = {};
|
|
303
|
+
response.headers.forEach((v, k) => {
|
|
304
|
+
// Skip transfer-encoding since we send the full body
|
|
305
|
+
if (k === 'transfer-encoding') return;
|
|
306
|
+
respHeaders[k] = v;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
res.writeHead(response.status, respHeaders);
|
|
310
|
+
res.end(responseText);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
313
|
+
this.exchanges.push({
|
|
314
|
+
request: capturedReq,
|
|
315
|
+
response: { status: 502, body: { error: message } },
|
|
316
|
+
});
|
|
317
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify({ error: `Proxy error: ${message}` }));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private readBody(req: IncomingMessage): Promise<string> {
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
const chunks: Buffer[] = [];
|
|
325
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
326
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
327
|
+
req.on('error', reject);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Go code generation -- produces main.go that calls SDK methods via proxy
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function detectModulePath(sdkPath: string): string {
|
|
337
|
+
const goModPath = resolve(sdkPath, 'go.mod');
|
|
338
|
+
if (existsSync(goModPath)) {
|
|
339
|
+
const goMod = readFileSync(goModPath, 'utf-8');
|
|
340
|
+
const match = goMod.match(/^module\s+(\S+)/m);
|
|
341
|
+
if (match) return match[1];
|
|
342
|
+
}
|
|
343
|
+
return 'github.com/workos/workos-go/v4';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function generateGoImports(
|
|
347
|
+
modulePath: string,
|
|
348
|
+
servicePackages: Set<string>,
|
|
349
|
+
needsJson: boolean,
|
|
350
|
+
needsServicePkg: boolean,
|
|
351
|
+
): string {
|
|
352
|
+
const lines: string[] = [];
|
|
353
|
+
lines.push('import (');
|
|
354
|
+
lines.push('\t"context"');
|
|
355
|
+
if (needsJson) {
|
|
356
|
+
lines.push('\t"encoding/json"');
|
|
357
|
+
}
|
|
358
|
+
lines.push('\t"fmt"');
|
|
359
|
+
lines.push('\t"os"');
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push(`\tworkos "${modulePath}/pkg"`);
|
|
362
|
+
if (needsServicePkg) {
|
|
363
|
+
for (const pkg of [...servicePackages].sort()) {
|
|
364
|
+
lines.push(`\t"${modulePath}/pkg/${pkg}"`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
lines.push(')');
|
|
368
|
+
return lines.join('\n');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function generateGoPayloadStruct(payload: Record<string, unknown>, optsType: string, servicePackage: string): string {
|
|
372
|
+
const lines: string[] = [];
|
|
373
|
+
lines.push(`${servicePackage}.${optsType}{`);
|
|
374
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
375
|
+
const goField = goFieldName(key);
|
|
376
|
+
lines.push(`\t\t${goField}: ${goLiteral(value)},`);
|
|
377
|
+
}
|
|
378
|
+
lines.push('\t}');
|
|
379
|
+
return lines.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function goLiteral(value: unknown): string {
|
|
383
|
+
if (value === null || value === undefined) return 'nil';
|
|
384
|
+
if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
385
|
+
if (typeof value === 'number') {
|
|
386
|
+
if (Number.isInteger(value)) return String(value);
|
|
387
|
+
return String(value);
|
|
388
|
+
}
|
|
389
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
390
|
+
if (Array.isArray(value)) {
|
|
391
|
+
if (value.length === 0) return 'nil';
|
|
392
|
+
// Simple string arrays
|
|
393
|
+
const items = value.map((v) => goLiteral(v)).join(', ');
|
|
394
|
+
return `[]interface{}{${items}}`;
|
|
395
|
+
}
|
|
396
|
+
if (typeof value === 'object') {
|
|
397
|
+
const obj = value as Record<string, unknown>;
|
|
398
|
+
const entries = Object.entries(obj);
|
|
399
|
+
if (entries.length === 0) return 'map[string]interface{}{}';
|
|
400
|
+
const parts = entries.map(([k, v]) => `"${k}": ${goLiteral(v)}`).join(', ');
|
|
401
|
+
return `map[string]interface{}{${parts}}`;
|
|
402
|
+
}
|
|
403
|
+
return `"${String(value)}"`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function generateGoCallBlock(
|
|
407
|
+
op: Operation,
|
|
408
|
+
resolution: MethodResolution,
|
|
409
|
+
pathParams: Record<string, string>,
|
|
410
|
+
spec: any,
|
|
411
|
+
callIndex: number,
|
|
412
|
+
): string {
|
|
413
|
+
const lines: string[] = [];
|
|
414
|
+
const servicePackage = goServicePackageName(resolution.service);
|
|
415
|
+
const method = resolution.method;
|
|
416
|
+
|
|
417
|
+
// Build arguments
|
|
418
|
+
const args: string[] = ['ctx'];
|
|
419
|
+
|
|
420
|
+
// Path params (positional string args)
|
|
421
|
+
for (const p of op.pathParams) {
|
|
422
|
+
args.push(`"${pathParams[p.name] || ''}"`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Request body opts struct
|
|
426
|
+
if (op.requestBody) {
|
|
427
|
+
const payload = generatePayload(op, spec);
|
|
428
|
+
if (payload && Object.keys(payload).length > 0) {
|
|
429
|
+
const optsType = `${method}Opts`;
|
|
430
|
+
args.push(generateGoPayloadStruct(payload, optsType, servicePackage));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Paginated operations: pass opts with Limit=1
|
|
435
|
+
if (op.pagination && !op.requestBody) {
|
|
436
|
+
const extraParams = op.queryParams.filter((p: any) => !['limit', 'before', 'after', 'order'].includes(p.name));
|
|
437
|
+
if (extraParams.length > 0) {
|
|
438
|
+
// Match the emitter convention: List → ListFilterOpts, others → ${method}Opts
|
|
439
|
+
const optsType = method === 'List' ? 'ListFilterOpts' : `${method}Opts`;
|
|
440
|
+
args.push(`${servicePackage}.${optsType}{Limit: 1}`);
|
|
441
|
+
} else {
|
|
442
|
+
args.push(`${servicePackage}.ListOpts{Limit: 1}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Determine the service accessor on the client
|
|
447
|
+
const serviceProp = goExportedName(resolution.service);
|
|
448
|
+
|
|
449
|
+
lines.push(`\t// Call ${callIndex}: ${op.httpMethod.toUpperCase()} ${op.path}`);
|
|
450
|
+
lines.push(`\tfmt.Fprintf(os.Stderr, "CALL_START:${callIndex}\\n")`);
|
|
451
|
+
|
|
452
|
+
// Determine return type: paginated and GET-with-response return (result, error),
|
|
453
|
+
// DELETE returns just error
|
|
454
|
+
const isDelete = op.httpMethod === 'delete';
|
|
455
|
+
const hasResponse = !isDelete;
|
|
456
|
+
|
|
457
|
+
if (hasResponse) {
|
|
458
|
+
lines.push(`\tresult${callIndex}, err${callIndex} := client.${serviceProp}.${method}(${args.join(', ')})`);
|
|
459
|
+
lines.push(`\tif err${callIndex} != nil {`);
|
|
460
|
+
lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_ERROR:${callIndex}:%s\\n", err${callIndex}.Error())`);
|
|
461
|
+
lines.push('\t} else {');
|
|
462
|
+
lines.push(`\t\tjsonResult${callIndex}, _ := json.Marshal(result${callIndex})`);
|
|
463
|
+
lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_OK:${callIndex}:%s\\n", string(jsonResult${callIndex}))`);
|
|
464
|
+
lines.push('\t}');
|
|
465
|
+
} else {
|
|
466
|
+
lines.push(`\terr${callIndex} := client.${serviceProp}.${method}(${args.join(', ')})`);
|
|
467
|
+
lines.push(`\tif err${callIndex} != nil {`);
|
|
468
|
+
lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_ERROR:${callIndex}:%s\\n", err${callIndex}.Error())`);
|
|
469
|
+
lines.push('\t} else {');
|
|
470
|
+
lines.push(`\t\tfmt.Fprintf(os.Stderr, "CALL_OK:${callIndex}:\\n")`);
|
|
471
|
+
lines.push('\t}');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
lines.push(`\tfmt.Fprintf(os.Stderr, "CALL_END:${callIndex}\\n")`);
|
|
475
|
+
lines.push('');
|
|
476
|
+
|
|
477
|
+
return lines.join('\n');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Planned call type for wave-based batching
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
interface PlannedCall {
|
|
485
|
+
index: number;
|
|
486
|
+
op: Operation;
|
|
487
|
+
irService: string;
|
|
488
|
+
resolution: MethodResolution;
|
|
489
|
+
pathParams: Record<string, string>;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// Main
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
async function main(): Promise<void> {
|
|
497
|
+
const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
|
|
498
|
+
|
|
499
|
+
if (!sdkPath) {
|
|
500
|
+
console.error('--sdk-path is required');
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
|
|
505
|
+
if (!apiKey) {
|
|
506
|
+
console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Load config
|
|
511
|
+
loadSmokeConfig(smokeConfig);
|
|
512
|
+
|
|
513
|
+
// Parse spec
|
|
514
|
+
console.log('Parsing spec...');
|
|
515
|
+
const spec = await parseSpec(specPath);
|
|
516
|
+
console.log(`Spec: ${spec.name} v${spec.version}`);
|
|
517
|
+
|
|
518
|
+
// Load manifest
|
|
519
|
+
const manifest = loadManifest(sdkPath);
|
|
520
|
+
|
|
521
|
+
const baseUrl = process.env.WORKOS_BASE_URL || spec.baseUrl;
|
|
522
|
+
|
|
523
|
+
// Start capture proxy
|
|
524
|
+
const proxy = new CaptureProxy(baseUrl);
|
|
525
|
+
const proxyPort = await proxy.start();
|
|
526
|
+
console.log(`Proxy started on port ${proxyPort}`);
|
|
527
|
+
|
|
528
|
+
// Plan operations
|
|
529
|
+
const groups = planOperations(spec);
|
|
530
|
+
const ids = new IdRegistry();
|
|
531
|
+
const exchanges: CapturedExchange[] = [];
|
|
532
|
+
const delayMs = Number(process.env.SMOKE_DELAY_MS) || 200;
|
|
533
|
+
|
|
534
|
+
let successCount = 0;
|
|
535
|
+
let errorCount = 0;
|
|
536
|
+
let skipCount = 0;
|
|
537
|
+
let unexpectedCount = 0;
|
|
538
|
+
|
|
539
|
+
// Temp directory for Go compilation — one main.go per wave
|
|
540
|
+
const tmpDir = resolve(sdkPath, '.smoke-tmp');
|
|
541
|
+
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
|
|
542
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
543
|
+
|
|
544
|
+
// Detect Go module path from SDK
|
|
545
|
+
const modulePath = detectModulePath(sdkPath);
|
|
546
|
+
|
|
547
|
+
// Write go.mod for the temp module that references the local SDK.
|
|
548
|
+
// The pseudo-version must match the major version suffix in the module path
|
|
549
|
+
// (e.g. /v4 requires v4.x.x).
|
|
550
|
+
const majorMatch = modulePath.match(/\/v(\d+)$/);
|
|
551
|
+
const pseudoVersion = majorMatch ? `v${majorMatch[1]}.0.0` : 'v0.0.0';
|
|
552
|
+
const tmpGoMod = [
|
|
553
|
+
'module smoke-test-go',
|
|
554
|
+
'',
|
|
555
|
+
'go 1.21',
|
|
556
|
+
'',
|
|
557
|
+
`require ${modulePath} ${pseudoVersion}`,
|
|
558
|
+
'',
|
|
559
|
+
`replace ${modulePath} => ${resolve(sdkPath)}`,
|
|
560
|
+
].join('\n');
|
|
561
|
+
writeFileSync(resolve(tmpDir, 'go.mod'), tmpGoMod);
|
|
562
|
+
writeFileSync(resolve(tmpDir, 'go.sum'), '');
|
|
563
|
+
|
|
564
|
+
// Use wave-based planning: execute parameterless ops first, extract IDs,
|
|
565
|
+
// then plan the next wave of ops whose path params are now resolvable.
|
|
566
|
+
let globalCallIndex = 0;
|
|
567
|
+
|
|
568
|
+
const waveIterator = planWaves(groups, ids, (op, irService) => {
|
|
569
|
+
const resolution = resolveMethod(op, irService, manifest);
|
|
570
|
+
return resolution !== null;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
let waveNumber = 0;
|
|
574
|
+
let waveResult = waveIterator.next();
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
while (!waveResult.done) {
|
|
578
|
+
const wave: OperationWave = waveResult.value;
|
|
579
|
+
waveNumber++;
|
|
580
|
+
|
|
581
|
+
// Build planned calls for this wave, resolving methods
|
|
582
|
+
const plannedCalls: PlannedCall[] = [];
|
|
583
|
+
const waveSkipped: Array<{ op: Operation; irService: string; reason: string }> = [];
|
|
584
|
+
|
|
585
|
+
for (const { op, irService, pathParams } of wave.calls) {
|
|
586
|
+
const resolution = resolveMethod(op, irService, manifest);
|
|
587
|
+
if (!resolution) {
|
|
588
|
+
waveSkipped.push({ op, irService, reason: 'No matching SDK method' });
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
plannedCalls.push({
|
|
592
|
+
index: globalCallIndex++,
|
|
593
|
+
op,
|
|
594
|
+
irService,
|
|
595
|
+
resolution,
|
|
596
|
+
pathParams,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Record skipped exchanges for this wave
|
|
601
|
+
for (const skip of waveSkipped) {
|
|
602
|
+
exchanges.push(makeSkippedExchange(skip.op, skip.irService, skip.reason));
|
|
603
|
+
skipCount++;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (plannedCalls.length === 0) {
|
|
607
|
+
waveResult = waveIterator.next();
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
console.log(`\n=== Wave ${waveNumber} (${plannedCalls.length} operations) ===`);
|
|
612
|
+
|
|
613
|
+
// Collect all service packages and determine if json import is needed
|
|
614
|
+
const servicePackages = new Set<string>();
|
|
615
|
+
let needsJson = false;
|
|
616
|
+
let needsServicePkg = false;
|
|
617
|
+
|
|
618
|
+
for (const call of plannedCalls) {
|
|
619
|
+
servicePackages.add(goServicePackageName(call.resolution.service));
|
|
620
|
+
if (call.op.httpMethod !== 'delete') needsJson = true;
|
|
621
|
+
if (call.op.requestBody || call.op.pagination) needsServicePkg = true;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Generate all call blocks for this wave
|
|
625
|
+
const callBlocks: string[] = [];
|
|
626
|
+
for (const call of plannedCalls) {
|
|
627
|
+
callBlocks.push(generateGoCallBlock(call.op, call.resolution, call.pathParams, spec, call.index));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const imports = generateGoImports(modulePath, servicePackages, needsJson, needsServicePkg);
|
|
631
|
+
|
|
632
|
+
const goSource = [
|
|
633
|
+
'package main',
|
|
634
|
+
'',
|
|
635
|
+
imports,
|
|
636
|
+
'',
|
|
637
|
+
'func main() {',
|
|
638
|
+
`\tclient := workos.NewClient("${apiKey}", workos.WithEndpoint("http://127.0.0.1:${proxyPort}"))`,
|
|
639
|
+
'\tctx := context.Background()',
|
|
640
|
+
'',
|
|
641
|
+
...callBlocks,
|
|
642
|
+
'}',
|
|
643
|
+
].join('\n');
|
|
644
|
+
|
|
645
|
+
const mainGoPath = resolve(tmpDir, 'main.go');
|
|
646
|
+
writeFileSync(mainGoPath, goSource);
|
|
647
|
+
|
|
648
|
+
// Clear proxy exchanges before running this wave
|
|
649
|
+
proxy.clearExchanges();
|
|
650
|
+
const waveStart = Date.now();
|
|
651
|
+
|
|
652
|
+
// Step 1: Build (sync — no proxy needed during compilation)
|
|
653
|
+
let buildError: string | null = null;
|
|
654
|
+
try {
|
|
655
|
+
execSync('go build -o smoke-driver main.go', {
|
|
656
|
+
cwd: tmpDir,
|
|
657
|
+
timeout: 120_000,
|
|
658
|
+
env: { ...process.env, GOPATH: process.env.GOPATH || resolve(process.env.HOME || '~', 'go') },
|
|
659
|
+
encoding: 'utf-8',
|
|
660
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
661
|
+
});
|
|
662
|
+
} catch (err: any) {
|
|
663
|
+
const stderr = typeof err.stderr === 'string' ? err.stderr : '';
|
|
664
|
+
buildError = stderr.trim().split('\n').slice(0, 5).join(' ') || 'go build failed';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (buildError) {
|
|
668
|
+
// Build failure affects entire wave
|
|
669
|
+
const elapsed = Date.now() - waveStart;
|
|
670
|
+
for (const call of plannedCalls) {
|
|
671
|
+
exchanges.push({
|
|
672
|
+
...makeSkippedExchange(call.op, call.irService, buildError),
|
|
673
|
+
outcome: 'api-error',
|
|
674
|
+
durationMs: elapsed,
|
|
675
|
+
});
|
|
676
|
+
errorCount++;
|
|
677
|
+
console.log(` X ${call.op.name} -- ${buildError}`);
|
|
678
|
+
}
|
|
679
|
+
waveResult = waveIterator.next();
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Step 2: Run (async — proxy needs the event loop to handle HTTP)
|
|
684
|
+
// Track per-call captures using stderr markers
|
|
685
|
+
const callResults = new Map<
|
|
686
|
+
number,
|
|
687
|
+
{
|
|
688
|
+
captureIndexBefore: number;
|
|
689
|
+
captureIndexAfter: number;
|
|
690
|
+
error?: string;
|
|
691
|
+
startTime: number;
|
|
692
|
+
endTime: number;
|
|
693
|
+
}
|
|
694
|
+
>();
|
|
695
|
+
|
|
696
|
+
let currentCallStart = Date.now();
|
|
697
|
+
let currentCapturesBefore = 0;
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
await new Promise<void>((resolveRun, rejectRun) => {
|
|
701
|
+
const child = spawn(resolve(tmpDir, 'smoke-driver'), [], {
|
|
702
|
+
cwd: tmpDir,
|
|
703
|
+
env: process.env,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
let stderrBuf = '';
|
|
707
|
+
|
|
708
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
709
|
+
stderrBuf += data.toString();
|
|
710
|
+
const lines = stderrBuf.split('\n');
|
|
711
|
+
stderrBuf = lines.pop() || '';
|
|
712
|
+
|
|
713
|
+
const proxyExchanges = proxy.getExchanges();
|
|
714
|
+
|
|
715
|
+
for (const line of lines) {
|
|
716
|
+
const trimmed = line.trim();
|
|
717
|
+
if (trimmed.startsWith('CALL_START:')) {
|
|
718
|
+
currentCallStart = Date.now();
|
|
719
|
+
currentCapturesBefore = proxyExchanges.length;
|
|
720
|
+
} else if (trimmed.startsWith('CALL_OK:')) {
|
|
721
|
+
const rest = trimmed.slice('CALL_OK:'.length);
|
|
722
|
+
const colonIdx = rest.indexOf(':');
|
|
723
|
+
const idx = parseInt(rest.slice(0, colonIdx), 10);
|
|
724
|
+
if (callResults.has(idx)) continue;
|
|
725
|
+
callResults.set(idx, {
|
|
726
|
+
captureIndexBefore: currentCapturesBefore,
|
|
727
|
+
captureIndexAfter: proxy.getExchanges().length,
|
|
728
|
+
startTime: currentCallStart,
|
|
729
|
+
endTime: Date.now(),
|
|
730
|
+
});
|
|
731
|
+
} else if (trimmed.startsWith('CALL_ERROR:')) {
|
|
732
|
+
const rest = trimmed.slice('CALL_ERROR:'.length);
|
|
733
|
+
const colonIdx = rest.indexOf(':');
|
|
734
|
+
const idx = parseInt(rest.slice(0, colonIdx), 10);
|
|
735
|
+
const errMsg = rest.slice(colonIdx + 1);
|
|
736
|
+
if (callResults.has(idx)) continue;
|
|
737
|
+
callResults.set(idx, {
|
|
738
|
+
captureIndexBefore: currentCapturesBefore,
|
|
739
|
+
captureIndexAfter: proxy.getExchanges().length,
|
|
740
|
+
error: errMsg,
|
|
741
|
+
startTime: currentCallStart,
|
|
742
|
+
endTime: Date.now(),
|
|
743
|
+
});
|
|
744
|
+
} else if (trimmed.startsWith('CALL_END:')) {
|
|
745
|
+
const idx = parseInt(trimmed.slice('CALL_END:'.length), 10);
|
|
746
|
+
const existing = callResults.get(idx);
|
|
747
|
+
if (existing) {
|
|
748
|
+
existing.captureIndexAfter = proxy.getExchanges().length;
|
|
749
|
+
existing.endTime = Date.now();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const timeout = setTimeout(() => {
|
|
756
|
+
child.kill('SIGKILL');
|
|
757
|
+
rejectRun(new Error('Go binary timed out after 60s'));
|
|
758
|
+
}, 60_000);
|
|
759
|
+
|
|
760
|
+
child.on('close', () => {
|
|
761
|
+
clearTimeout(timeout);
|
|
762
|
+
// Process any remaining stderr
|
|
763
|
+
if (stderrBuf.trim()) {
|
|
764
|
+
const trimmed = stderrBuf.trim();
|
|
765
|
+
const proxyExchanges = proxy.getExchanges();
|
|
766
|
+
if (trimmed.startsWith('CALL_END:')) {
|
|
767
|
+
const idx = parseInt(trimmed.slice('CALL_END:'.length), 10);
|
|
768
|
+
const existing = callResults.get(idx);
|
|
769
|
+
if (existing) {
|
|
770
|
+
existing.captureIndexAfter = proxyExchanges.length;
|
|
771
|
+
existing.endTime = Date.now();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
resolveRun();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
child.on('error', (err) => {
|
|
779
|
+
clearTimeout(timeout);
|
|
780
|
+
rejectRun(err);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
} catch (err) {
|
|
784
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
785
|
+
console.error(`Wave ${waveNumber} execution error: ${message}`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
await delay(delayMs);
|
|
789
|
+
|
|
790
|
+
// Process results for this wave — extract IDs so the next wave can use them
|
|
791
|
+
const proxyExchanges = proxy.getExchanges();
|
|
792
|
+
|
|
793
|
+
for (const call of plannedCalls) {
|
|
794
|
+
const { index, op, irService, resolution } = call;
|
|
795
|
+
const isTopLevel = op.pathParams.length === 0;
|
|
796
|
+
const result = callResults.get(index);
|
|
797
|
+
|
|
798
|
+
if (!result) {
|
|
799
|
+
exchanges.push({
|
|
800
|
+
...makeSkippedExchange(op, irService, 'Call did not execute (binary may have failed)'),
|
|
801
|
+
outcome: 'api-error',
|
|
802
|
+
durationMs: 0,
|
|
803
|
+
});
|
|
804
|
+
errorCount++;
|
|
805
|
+
console.log(` X ${op.name} -- did not execute`);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const elapsed = result.endTime - result.startTime;
|
|
810
|
+
|
|
811
|
+
if (result.captureIndexAfter <= result.captureIndexBefore) {
|
|
812
|
+
if (result.error) {
|
|
813
|
+
exchanges.push({
|
|
814
|
+
...makeSkippedExchange(op, irService, result.error),
|
|
815
|
+
outcome: 'api-error',
|
|
816
|
+
durationMs: elapsed,
|
|
817
|
+
});
|
|
818
|
+
errorCount++;
|
|
819
|
+
console.log(` X ${op.name} -- ${result.error.split('\n')[0]}`);
|
|
820
|
+
} else {
|
|
821
|
+
exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
|
|
822
|
+
skipCount++;
|
|
823
|
+
console.log(` SKIP ${op.name} -- no HTTP capture`);
|
|
824
|
+
}
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const captured = proxyExchanges[result.captureIndexAfter - 1];
|
|
829
|
+
const exchange = buildExchange(op, irService, captured, elapsed, resolution);
|
|
830
|
+
|
|
831
|
+
if (result.error) {
|
|
832
|
+
exchange.error = result.error;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Extract IDs from response (critical: feeds the next wave)
|
|
836
|
+
ids.extractAndStore(irService, captured.response.body, isTopLevel);
|
|
837
|
+
|
|
838
|
+
if (exchange.unexpectedStatus) {
|
|
839
|
+
unexpectedCount++;
|
|
840
|
+
console.log(` ! ${op.name} -> ${captured.response.status} (unexpected)`);
|
|
841
|
+
} else if (exchange.outcome === 'api-error') {
|
|
842
|
+
errorCount++;
|
|
843
|
+
console.log(` X ${op.name} -> ${captured.response.status}`);
|
|
844
|
+
} else {
|
|
845
|
+
successCount++;
|
|
846
|
+
console.log(` OK ${op.name} -> ${captured.response.status} (${elapsed}ms)`);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
exchanges.push(exchange);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Advance to the next wave (IDs from this wave are now in the registry)
|
|
853
|
+
waveResult = waveIterator.next();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Record any operations that could never be resolved
|
|
857
|
+
if (waveResult.done && waveResult.value) {
|
|
858
|
+
for (const unresolved of waveResult.value) {
|
|
859
|
+
exchanges.push(makeSkippedExchange(unresolved.operation, unresolved.service, 'Missing path param IDs'));
|
|
860
|
+
skipCount++;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} finally {
|
|
864
|
+
await proxy.stop();
|
|
865
|
+
// Clean up temp directory
|
|
866
|
+
try {
|
|
867
|
+
rmSync(tmpDir, { recursive: true });
|
|
868
|
+
} catch {
|
|
869
|
+
// best effort
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Write results
|
|
874
|
+
const results: SmokeResults = {
|
|
875
|
+
source: 'sdk-go',
|
|
876
|
+
timestamp: new Date().toISOString(),
|
|
877
|
+
specVersion: spec.version,
|
|
878
|
+
exchanges,
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
const outputPath = 'smoke-results-sdk-go.json';
|
|
882
|
+
writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
883
|
+
console.log(`\nResults written to ${outputPath}`);
|
|
884
|
+
|
|
885
|
+
// Summary
|
|
886
|
+
const total = exchanges.length;
|
|
887
|
+
console.log(`\n=== Summary ===`);
|
|
888
|
+
console.log(` Total: ${total}`);
|
|
889
|
+
console.log(` Success: ${successCount}`);
|
|
890
|
+
console.log(` API errors: ${errorCount}`);
|
|
891
|
+
console.log(` Skipped: ${skipCount}`);
|
|
892
|
+
if (unexpectedCount > 0) {
|
|
893
|
+
console.log(` Unexpected: ${unexpectedCount}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// Helpers
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
|
|
902
|
+
return {
|
|
903
|
+
operationId: op.name,
|
|
904
|
+
service,
|
|
905
|
+
operationName: op.name,
|
|
906
|
+
request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
|
|
907
|
+
response: { status: 0, body: null },
|
|
908
|
+
outcome: 'skipped',
|
|
909
|
+
error: reason,
|
|
910
|
+
durationMs: 0,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function buildExchange(
|
|
915
|
+
op: Operation,
|
|
916
|
+
service: string,
|
|
917
|
+
capture: ProxyExchange,
|
|
918
|
+
durationMs: number,
|
|
919
|
+
resolution: MethodResolution,
|
|
920
|
+
): CapturedExchange {
|
|
921
|
+
const status = capture.response.status;
|
|
922
|
+
const expectedCodes = getExpectedStatusCodes(op);
|
|
923
|
+
const unexpected = isUnexpectedStatus(status, op);
|
|
924
|
+
|
|
925
|
+
return {
|
|
926
|
+
operationId: op.name,
|
|
927
|
+
service,
|
|
928
|
+
operationName: op.name,
|
|
929
|
+
request: capture.request,
|
|
930
|
+
response: capture.response,
|
|
931
|
+
outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
|
|
932
|
+
unexpectedStatus: unexpected || undefined,
|
|
933
|
+
expectedStatusCodes: expectedCodes,
|
|
934
|
+
durationMs,
|
|
935
|
+
provenance: {
|
|
936
|
+
resolutionTier: resolution.tier,
|
|
937
|
+
resolutionConfidence: resolution.confidence,
|
|
938
|
+
sdkMethodName: `${resolution.service}.${resolution.method}`,
|
|
939
|
+
captureIndex: 0,
|
|
940
|
+
totalCaptures: 1,
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
main().catch((err) => {
|
|
946
|
+
console.error('Fatal error:', err);
|
|
947
|
+
process.exit(1);
|
|
948
|
+
});
|