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