@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,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node SDK smoke test — captures wire-level HTTP exchanges from the generated
|
|
3
|
+
* WorkOS Node SDK and outputs SmokeResults JSON for diff comparison.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx tsx smoke/sdk-node.ts --spec ../openapi-spec/spec/open-api-spec.yaml --sdk-path ./sdk
|
|
7
|
+
*
|
|
8
|
+
* Requires API_KEY or WORKOS_API_KEY env var.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
parseSpec,
|
|
15
|
+
planOperations,
|
|
16
|
+
generateCamelPayload,
|
|
17
|
+
generateCamelQueryParams,
|
|
18
|
+
IdRegistry,
|
|
19
|
+
delay,
|
|
20
|
+
parseCliArgs,
|
|
21
|
+
loadSmokeConfig,
|
|
22
|
+
getExpectedStatusCodes,
|
|
23
|
+
isUnexpectedStatus,
|
|
24
|
+
toCamelCase,
|
|
25
|
+
SERVICE_PROPERTY_MAP,
|
|
26
|
+
} from '@workos/oagen/smoke';
|
|
27
|
+
import type { CapturedExchange, SmokeResults, ExchangeProvenance } from '@workos/oagen/smoke';
|
|
28
|
+
import type { Operation } from '@workos/oagen';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Types
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface ManifestEntry {
|
|
35
|
+
sdkMethod: string;
|
|
36
|
+
service: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CapturedRequest {
|
|
40
|
+
method: string;
|
|
41
|
+
path: string;
|
|
42
|
+
queryParams: Record<string, string>;
|
|
43
|
+
body: unknown | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CapturedResponse {
|
|
47
|
+
status: number;
|
|
48
|
+
body: unknown | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// HTTP Interception
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
let currentCapture: { request: CapturedRequest; response: CapturedResponse } | null = null;
|
|
56
|
+
const originalFetch = globalThis.fetch;
|
|
57
|
+
|
|
58
|
+
function interceptFetch(): void {
|
|
59
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
60
|
+
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
61
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
62
|
+
const path = url.pathname;
|
|
63
|
+
const queryParams: Record<string, string> = {};
|
|
64
|
+
url.searchParams.forEach((v, k) => {
|
|
65
|
+
queryParams[k] = v;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let body: unknown = null;
|
|
69
|
+
if (init?.body) {
|
|
70
|
+
try {
|
|
71
|
+
body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
|
|
72
|
+
} catch {
|
|
73
|
+
body = init.body;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const capturedReq: CapturedRequest = { method, path, queryParams, body };
|
|
78
|
+
|
|
79
|
+
const response = await originalFetch(input, init);
|
|
80
|
+
const cloned = response.clone();
|
|
81
|
+
|
|
82
|
+
let responseBody: unknown = null;
|
|
83
|
+
try {
|
|
84
|
+
responseBody = await cloned.json();
|
|
85
|
+
} catch {
|
|
86
|
+
// Not JSON — that's fine
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
currentCapture = {
|
|
90
|
+
request: capturedReq,
|
|
91
|
+
response: { status: response.status, body: responseBody },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return response;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function restoreFetch(): void {
|
|
99
|
+
globalThis.fetch = originalFetch;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Manifest loading
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
|
|
107
|
+
const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
|
|
108
|
+
if (!existsSync(manifestPath)) {
|
|
109
|
+
console.warn(`⚠ No smoke-manifest.json found at ${manifestPath}`);
|
|
110
|
+
console.warn(' Method resolution will rely on heuristic tiers — most operations may be skipped.');
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
114
|
+
const manifest = new Map<string, ManifestEntry>();
|
|
115
|
+
for (const [httpKey, entry] of Object.entries(raw)) {
|
|
116
|
+
manifest.set(httpKey, entry as ManifestEntry);
|
|
117
|
+
}
|
|
118
|
+
return manifest;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Method resolution
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
interface MethodResolution {
|
|
126
|
+
service: string;
|
|
127
|
+
method: string;
|
|
128
|
+
tier: ExchangeProvenance['resolutionTier'];
|
|
129
|
+
confidence: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveMethod(
|
|
133
|
+
op: Operation,
|
|
134
|
+
irService: string,
|
|
135
|
+
manifest: Map<string, ManifestEntry> | null,
|
|
136
|
+
): MethodResolution | null {
|
|
137
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
138
|
+
|
|
139
|
+
// Tier 0: Manifest match (primary for generated SDKs)
|
|
140
|
+
if (manifest) {
|
|
141
|
+
const entry = manifest.get(httpKey);
|
|
142
|
+
if (entry) {
|
|
143
|
+
return {
|
|
144
|
+
service: entry.service,
|
|
145
|
+
method: entry.sdkMethod,
|
|
146
|
+
tier: 'manifest',
|
|
147
|
+
confidence: 1.0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Tier 1: Exact match — IR operation name in camelCase
|
|
153
|
+
const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
|
|
154
|
+
const exactName = toCamelCase(op.name);
|
|
155
|
+
return {
|
|
156
|
+
service: sdkProp,
|
|
157
|
+
method: exactName,
|
|
158
|
+
tier: 'exact',
|
|
159
|
+
confidence: 0.8,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Argument construction
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function buildArgs(op: Operation, pathParams: Record<string, string>, spec: any): unknown[] {
|
|
168
|
+
const args: unknown[] = [];
|
|
169
|
+
|
|
170
|
+
// Positional path params
|
|
171
|
+
if (op.pathParams.length > 0) {
|
|
172
|
+
if (op.pathParams.length === 1 && !op.requestBody && op.queryParams.filter((p) => p.required).length === 0) {
|
|
173
|
+
// Simple case: single path param, no body/query → positional arg
|
|
174
|
+
args.push(pathParams[op.pathParams[0].name]);
|
|
175
|
+
} else {
|
|
176
|
+
// Multiple path params → individual positional args
|
|
177
|
+
for (const p of op.pathParams) {
|
|
178
|
+
args.push(pathParams[p.name]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Request body
|
|
184
|
+
if (op.requestBody) {
|
|
185
|
+
const payload = generateCamelPayload(op, spec);
|
|
186
|
+
if (payload) args.push(payload);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Query params (for non-paginated GETs with required query params)
|
|
190
|
+
if (!op.requestBody && op.queryParams.some((p) => p.required)) {
|
|
191
|
+
const queryOpts = generateCamelQueryParams(op, spec);
|
|
192
|
+
if (Object.keys(queryOpts).length > 0) args.push(queryOpts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Paginated operations may pass options
|
|
196
|
+
if (op.pagination && args.length === 0) {
|
|
197
|
+
args.push({ limit: 1 });
|
|
198
|
+
} else if (op.pagination && !op.requestBody) {
|
|
199
|
+
// If we already have path param args but it's paginated, merge limit
|
|
200
|
+
const lastArg = args[args.length - 1];
|
|
201
|
+
if (typeof lastArg === 'object' && lastArg !== null) {
|
|
202
|
+
(lastArg as Record<string, unknown>)['limit'] = 1;
|
|
203
|
+
} else {
|
|
204
|
+
args.push({ limit: 1 });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Idempotent POST: append empty options for idempotency key slot
|
|
209
|
+
if (op.injectIdempotencyKey && op.httpMethod === 'post') {
|
|
210
|
+
args.push({});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return args;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Main
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
async function main(): Promise<void> {
|
|
221
|
+
const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
|
|
222
|
+
|
|
223
|
+
if (!sdkPath) {
|
|
224
|
+
console.error('--sdk-path is required');
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
|
|
229
|
+
if (!apiKey) {
|
|
230
|
+
console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Load config
|
|
235
|
+
loadSmokeConfig(smokeConfig);
|
|
236
|
+
|
|
237
|
+
// Parse spec
|
|
238
|
+
console.log('Parsing spec...');
|
|
239
|
+
const spec = await parseSpec(specPath);
|
|
240
|
+
console.log(`Spec: ${spec.name} v${spec.version}`);
|
|
241
|
+
|
|
242
|
+
// Load manifest
|
|
243
|
+
const manifest = loadManifest(sdkPath);
|
|
244
|
+
|
|
245
|
+
// Import SDK dynamically from the sdk-path
|
|
246
|
+
const sdkEntryPoint = resolve(sdkPath, 'src/index.ts');
|
|
247
|
+
const sdkModule = await import(sdkEntryPoint);
|
|
248
|
+
const WorkOS = sdkModule.WorkOS || sdkModule.default?.WorkOS;
|
|
249
|
+
|
|
250
|
+
if (!WorkOS) {
|
|
251
|
+
console.error(`Could not find WorkOS class in ${sdkEntryPoint}`);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const baseUrl = process.env.WORKOS_BASE_URL || spec.baseUrl;
|
|
256
|
+
const client = new WorkOS({ apiKey, apiHostname: new URL(baseUrl).hostname });
|
|
257
|
+
|
|
258
|
+
// Plan operations
|
|
259
|
+
const groups = planOperations(spec);
|
|
260
|
+
const ids = new IdRegistry();
|
|
261
|
+
const exchanges: CapturedExchange[] = [];
|
|
262
|
+
const createdEntities: Array<{ service: string; id: string; deleteFn?: () => Promise<void> }> = [];
|
|
263
|
+
const delayMs = Number(process.env.SMOKE_DELAY_MS) || 200;
|
|
264
|
+
|
|
265
|
+
let successCount = 0;
|
|
266
|
+
let errorCount = 0;
|
|
267
|
+
let skipCount = 0;
|
|
268
|
+
let unexpectedCount = 0;
|
|
269
|
+
|
|
270
|
+
interceptFetch();
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
for (const group of groups) {
|
|
274
|
+
console.log(`\n--- ${group.service} (${group.operations.length} operations) ---`);
|
|
275
|
+
|
|
276
|
+
for (const planned of group.operations) {
|
|
277
|
+
const { operation: op, service: irService } = planned;
|
|
278
|
+
const isTopLevel = op.pathParams.length === 0;
|
|
279
|
+
|
|
280
|
+
// Resolve SDK method
|
|
281
|
+
const resolution = resolveMethod(op, irService, manifest);
|
|
282
|
+
if (!resolution) {
|
|
283
|
+
console.log(` SKIP ${op.name} — no matching SDK method`);
|
|
284
|
+
exchanges.push(makeSkippedExchange(op, irService, 'No matching SDK method'));
|
|
285
|
+
skipCount++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Get the resource accessor and method
|
|
290
|
+
const resource = (client as any)[resolution.service];
|
|
291
|
+
if (!resource) {
|
|
292
|
+
console.log(` SKIP ${op.name} — service "${resolution.service}" not found on client`);
|
|
293
|
+
exchanges.push(makeSkippedExchange(op, irService, `Service "${resolution.service}" not found`));
|
|
294
|
+
skipCount++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const methodFn = resource[resolution.method];
|
|
299
|
+
if (typeof methodFn !== 'function') {
|
|
300
|
+
console.log(` SKIP ${op.name} — method "${resolution.method}" not found on ${resolution.service}`);
|
|
301
|
+
exchanges.push(
|
|
302
|
+
makeSkippedExchange(op, irService, `Method "${resolution.method}" not found on ${resolution.service}`),
|
|
303
|
+
);
|
|
304
|
+
skipCount++;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Resolve path params
|
|
309
|
+
let pathParams: Record<string, string> = {};
|
|
310
|
+
if (op.pathParams.length > 0) {
|
|
311
|
+
const resolved = ids.resolvePathParams(op, irService);
|
|
312
|
+
if (!resolved) {
|
|
313
|
+
console.log(` SKIP ${op.name} — missing path param IDs`);
|
|
314
|
+
exchanges.push(makeSkippedExchange(op, irService, 'Missing path param IDs'));
|
|
315
|
+
skipCount++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
pathParams = resolved;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Build arguments
|
|
322
|
+
const args = buildArgs(op, pathParams, spec);
|
|
323
|
+
|
|
324
|
+
// Execute
|
|
325
|
+
currentCapture = null;
|
|
326
|
+
const start = Date.now();
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
await methodFn.call(resource, ...args);
|
|
330
|
+
const elapsed = Date.now() - start;
|
|
331
|
+
|
|
332
|
+
if (!currentCapture) {
|
|
333
|
+
console.log(` SKIP ${op.name} — no HTTP capture (method may not make HTTP calls)`);
|
|
334
|
+
exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
|
|
335
|
+
skipCount++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const exchange = buildExchange(op, irService, currentCapture, elapsed, resolution);
|
|
340
|
+
|
|
341
|
+
// Extract IDs from response
|
|
342
|
+
const responseBody = currentCapture.response.body;
|
|
343
|
+
ids.extractAndStore(irService, responseBody, isTopLevel);
|
|
344
|
+
|
|
345
|
+
// Track created entities for cleanup
|
|
346
|
+
if (op.httpMethod === 'post' && currentCapture.response.status < 300) {
|
|
347
|
+
const body = responseBody as Record<string, unknown> | null;
|
|
348
|
+
if (body?.id && typeof body.id === 'string') {
|
|
349
|
+
// Find the delete method for this service
|
|
350
|
+
const deleteResolution = findDeleteMethod(irService, manifest);
|
|
351
|
+
if (deleteResolution) {
|
|
352
|
+
const deleteResource = (client as any)[deleteResolution.service];
|
|
353
|
+
const deleteFn = deleteResource?.[deleteResolution.method];
|
|
354
|
+
if (typeof deleteFn === 'function') {
|
|
355
|
+
createdEntities.push({
|
|
356
|
+
service: irService,
|
|
357
|
+
id: body.id as string,
|
|
358
|
+
deleteFn: () => deleteFn.call(deleteResource, body.id),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (exchange.unexpectedStatus) {
|
|
366
|
+
unexpectedCount++;
|
|
367
|
+
console.log(` ⚠ ${op.name} → ${currentCapture.response.status} (unexpected)`);
|
|
368
|
+
} else if (exchange.outcome === 'api-error') {
|
|
369
|
+
errorCount++;
|
|
370
|
+
console.log(` ✗ ${op.name} → ${currentCapture.response.status}`);
|
|
371
|
+
} else {
|
|
372
|
+
successCount++;
|
|
373
|
+
console.log(` ✓ ${op.name} → ${currentCapture.response.status} (${elapsed}ms)`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
exchanges.push(exchange);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
const elapsed = Date.now() - start;
|
|
379
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
380
|
+
|
|
381
|
+
if (currentCapture) {
|
|
382
|
+
const exchange = buildExchange(op, irService, currentCapture, elapsed, resolution);
|
|
383
|
+
exchange.error = message;
|
|
384
|
+
exchanges.push(exchange);
|
|
385
|
+
errorCount++;
|
|
386
|
+
console.log(` ✗ ${op.name} → ${currentCapture.response.status} (${message})`);
|
|
387
|
+
} else {
|
|
388
|
+
exchanges.push({
|
|
389
|
+
...makeSkippedExchange(op, irService, message),
|
|
390
|
+
outcome: 'api-error',
|
|
391
|
+
durationMs: elapsed,
|
|
392
|
+
});
|
|
393
|
+
errorCount++;
|
|
394
|
+
console.log(` ✗ ${op.name} — ${message}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await delay(delayMs);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Cleanup created entities (reverse order)
|
|
403
|
+
if (createdEntities.length > 0) {
|
|
404
|
+
console.log(`\n--- Cleanup (${createdEntities.length} entities) ---`);
|
|
405
|
+
for (const entity of createdEntities.reverse()) {
|
|
406
|
+
try {
|
|
407
|
+
if (entity.deleteFn) {
|
|
408
|
+
await entity.deleteFn();
|
|
409
|
+
console.log(` ✓ Deleted ${entity.service} ${entity.id}`);
|
|
410
|
+
}
|
|
411
|
+
} catch (err) {
|
|
412
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
413
|
+
console.log(` ✗ Failed to delete ${entity.service} ${entity.id}: ${message}`);
|
|
414
|
+
}
|
|
415
|
+
await delay(delayMs);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} finally {
|
|
419
|
+
restoreFetch();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Write results
|
|
423
|
+
const results: SmokeResults = {
|
|
424
|
+
source: 'sdk-node',
|
|
425
|
+
timestamp: new Date().toISOString(),
|
|
426
|
+
specVersion: spec.version,
|
|
427
|
+
exchanges,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const outputPath = `smoke-results-sdk-node.json`;
|
|
431
|
+
writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
432
|
+
console.log(`\nResults written to ${outputPath}`);
|
|
433
|
+
|
|
434
|
+
// Summary
|
|
435
|
+
const total = exchanges.length;
|
|
436
|
+
console.log(`\n=== Summary ===`);
|
|
437
|
+
console.log(` Total: ${total}`);
|
|
438
|
+
console.log(` Success: ${successCount}`);
|
|
439
|
+
console.log(` API errors: ${errorCount}`);
|
|
440
|
+
console.log(` Skipped: ${skipCount}`);
|
|
441
|
+
if (unexpectedCount > 0) {
|
|
442
|
+
console.log(` Unexpected: ${unexpectedCount}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Helpers
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
|
|
451
|
+
return {
|
|
452
|
+
operationId: op.name,
|
|
453
|
+
service,
|
|
454
|
+
operationName: op.name,
|
|
455
|
+
request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
|
|
456
|
+
response: { status: 0, body: null },
|
|
457
|
+
outcome: 'skipped',
|
|
458
|
+
error: reason,
|
|
459
|
+
durationMs: 0,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function buildExchange(
|
|
464
|
+
op: Operation,
|
|
465
|
+
service: string,
|
|
466
|
+
capture: NonNullable<typeof currentCapture>,
|
|
467
|
+
durationMs: number,
|
|
468
|
+
resolution: MethodResolution,
|
|
469
|
+
): CapturedExchange {
|
|
470
|
+
const status = capture.response.status;
|
|
471
|
+
const expectedCodes = getExpectedStatusCodes(op);
|
|
472
|
+
const unexpected = isUnexpectedStatus(status, op);
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
operationId: op.name,
|
|
476
|
+
service,
|
|
477
|
+
operationName: op.name,
|
|
478
|
+
request: capture.request,
|
|
479
|
+
response: capture.response,
|
|
480
|
+
outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
|
|
481
|
+
unexpectedStatus: unexpected || undefined,
|
|
482
|
+
expectedStatusCodes: expectedCodes,
|
|
483
|
+
durationMs,
|
|
484
|
+
provenance: {
|
|
485
|
+
resolutionTier: resolution.tier,
|
|
486
|
+
resolutionConfidence: resolution.confidence,
|
|
487
|
+
sdkMethodName: `${resolution.service}.${resolution.method}`,
|
|
488
|
+
captureIndex: 0,
|
|
489
|
+
totalCaptures: 1,
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function findDeleteMethod(irService: string, manifest: Map<string, ManifestEntry> | null): MethodResolution | null {
|
|
495
|
+
if (!manifest) return null;
|
|
496
|
+
// Look for a DELETE method in the manifest that belongs to this service
|
|
497
|
+
for (const [httpKey, entry] of manifest.entries()) {
|
|
498
|
+
if (
|
|
499
|
+
httpKey.startsWith('DELETE ') &&
|
|
500
|
+
entry.service === (SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService))
|
|
501
|
+
) {
|
|
502
|
+
return {
|
|
503
|
+
service: entry.service,
|
|
504
|
+
method: entry.sdkMethod,
|
|
505
|
+
tier: 'manifest',
|
|
506
|
+
confidence: 1.0,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
main().catch((err) => {
|
|
514
|
+
console.error('Fatal error:', err);
|
|
515
|
+
process.exit(1);
|
|
516
|
+
});
|